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.
}
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
}
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
}
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(
}
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(
}
private readonly workerEventUpdated = (data: ChargingStationData): void => {
- this.uiServer.setChargingStationData(data.stationInfo.hashId, data)
+ if (this.uiServer.setChargingStationData(data.stationInfo.hashId, data)) {
+ this.uiServer.scheduleClientNotification()
+ }
}
}
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>()
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)
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 {
public abstract start (): void
public stop (): void {
+ clearTimeout(this.clientNotificationDebounceTimer)
this.stopHttpServer()
for (const uiService of this.uiServices.values()) {
uiService.stop()
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))
import {
MapStringifyFormat,
+ type ProtocolNotification,
type ProtocolRequest,
type ProtocolResponse,
+ ServerNotification,
type UIServerConfiguration,
WebSocketCloseEventStatusCode,
} from '../../types/index.js'
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) {
SUCCESS = 'success',
}
+export enum ServerNotification {
+ REFRESH = 'refresh',
+}
+
+export type ProtocolNotification = [ServerNotification]
+
export type ProtocolRequest = [UUIDv4, ProcedureName, RequestPayload]
export type ProtocolRequestHandler = (
AuthenticationType,
ProcedureName,
Protocol,
+ type ProtocolNotification,
type ProtocolRequest,
type ProtocolRequestHandler,
type ProtocolResponse,
type RequestPayload,
type ResponsePayload,
ResponseStatus,
+ ServerNotification,
} from './UIProtocol.js'
export type { UUIDv4 } from './UUID.js'
export {
type RequestPayload,
type ResponsePayload,
ResponseStatus,
+ ServerNotification,
type UIServerConfigurationSection,
type UUIDv4,
} from '@/types'
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 {
})
}
+ 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],
}
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 (
SUCCESS = 'success',
}
+export enum ServerNotification {
+ REFRESH = 'refresh',
+}
+
+export type ProtocolNotification = [ServerNotification]
+
export type ProtocolRequest = [UUIDv4, ProcedureName, RequestPayload]
export type ProtocolRequestHandler = (
AuthenticationType,
ProcedureName,
Protocol,
+ type ProtocolNotification,
type ProtocolResponse,
ProtocolVersion,
type RequestPayload,
type ResponsePayload,
ResponseStatus,
+ ServerNotification,
type SimulatorState,
} from './UIProtocol'
export type { UUIDv4 } from './UUID'
uiClient.unregisterWSEventListener('close', clearChargingStations)
}
+let unsubscribeRefresh: (() => void) | undefined
+
onMounted(() => {
registerWSEventListeners()
+ unsubscribeRefresh = uiClient.onRefresh(() => {
+ getChargingStations()
+ })
})
onUnmounted(() => {
unregisterWSEventListeners()
+ unsubscribeRefresh?.()
})
const uiServerConfigurations: {
OCPPVersion,
ProcedureName,
ResponseStatus,
+ ServerNotification,
} from '@/types'
import { toastMock } from '../setup'
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)
ws.simulateMessage({ notAnArray: true })
+ expect(toastMock.error).toHaveBeenCalledWith('Response not an array')
expect(consoleSpy).toHaveBeenCalledWith(
'Response not an array:',
expect.objectContaining({ notAnArray: true })
}).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()
})
})
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>
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(),