chore(deps-dev): apply updates
[e-mobility-charging-stations-simulator.git] / ui / web / src / composables / UIClient.ts
1 import {
2 ApplicationProtocol,
3 AuthenticationType,
4 type ChargingStationOptions,
5 ProcedureName,
6 type ProtocolResponse,
7 type RequestPayload,
8 type ResponsePayload,
9 ResponseStatus,
10 type UIServerConfigurationSection,
11 } from '@/types'
12 import { useToast } from 'vue-toast-notification'
13
14 import { randomUUID, validateUUID } from './Utils'
15
16 interface ResponseHandler {
17 procedureName: ProcedureName
18 reject: (reason?: unknown) => void
19 resolve: (value: PromiseLike<ResponsePayload> | ResponsePayload) => void
20 }
21
22 export class UIClient {
23 private static instance: null | UIClient = null
24 private responseHandlers: Map<
25 `${string}-${string}-${string}-${string}-${string}`,
26 ResponseHandler
27 >
28
29 private ws?: WebSocket
30
31 private constructor (private uiServerConfiguration: UIServerConfigurationSection) {
32 this.openWS()
33 this.responseHandlers = new Map<
34 `${string}-${string}-${string}-${string}-${string}`,
35 ResponseHandler
36 >()
37 }
38
39 public static getInstance (uiServerConfiguration?: UIServerConfigurationSection): UIClient {
40 if (UIClient.instance === null) {
41 if (uiServerConfiguration == null) {
42 throw new Error('Cannot initialize UIClient if no configuration is provided')
43 }
44 UIClient.instance = new UIClient(uiServerConfiguration)
45 }
46 return UIClient.instance
47 }
48
49 private openWS (): void {
50 const protocols =
51 this.uiServerConfiguration.authentication?.enabled === true &&
52 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
53 this.uiServerConfiguration.authentication.type === AuthenticationType.PROTOCOL_BASIC_AUTH
54 ? [
55 `${this.uiServerConfiguration.protocol}${this.uiServerConfiguration.version}`,
56 `authorization.basic.${btoa(
57 // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
58 `${this.uiServerConfiguration.authentication.username}:${this.uiServerConfiguration.authentication.password}`
59 ).replace(/={1,2}$/, '')}`,
60 ]
61 : `${this.uiServerConfiguration.protocol}${this.uiServerConfiguration.version}`
62 this.ws = new WebSocket(
63 `${
64 this.uiServerConfiguration.secure === true
65 ? ApplicationProtocol.WSS
66 : ApplicationProtocol.WS
67 }://${this.uiServerConfiguration.host}:${this.uiServerConfiguration.port.toString()}`,
68 protocols
69 )
70 this.ws.onopen = () => {
71 useToast().success(
72 `WebSocket to UI server '${this.uiServerConfiguration.host}:${this.uiServerConfiguration.port.toString()}' successfully opened`
73 )
74 }
75 this.ws.onmessage = this.responseHandler.bind(this)
76 this.ws.onerror = errorEvent => {
77 useToast().error(
78 `Error in WebSocket to UI server '${this.uiServerConfiguration.host}:${this.uiServerConfiguration.port.toString()}'`
79 )
80 console.error(
81 `Error in WebSocket to UI server '${this.uiServerConfiguration.host}:${this.uiServerConfiguration.port.toString()}'`,
82 errorEvent
83 )
84 }
85 this.ws.onclose = () => {
86 useToast().info('WebSocket to UI server closed')
87 }
88 }
89
90 private responseHandler (messageEvent: MessageEvent<string>): void {
91 let response: ProtocolResponse
92 try {
93 response = JSON.parse(messageEvent.data) as ProtocolResponse
94 } catch (error) {
95 useToast().error('Invalid response JSON format')
96 console.error('Invalid response JSON format', error)
97 return
98 }
99
100 if (!Array.isArray(response)) {
101 useToast().error('Response not an array')
102 console.error('Response not an array:', response)
103 return
104 }
105
106 const [uuid, responsePayload] = response
107
108 if (!validateUUID(uuid)) {
109 useToast().error('Response UUID field is invalid')
110 console.error('Response UUID field is invalid:', response)
111 return
112 }
113
114 if (this.responseHandlers.has(uuid)) {
115 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
116 const { procedureName, reject, resolve } = this.responseHandlers.get(uuid)!
117 switch (responsePayload.status) {
118 case ResponseStatus.SUCCESS:
119 resolve(responsePayload)
120 break
121 case ResponseStatus.FAILURE:
122 reject(responsePayload)
123 break
124 default:
125 reject(
126 new Error(
127 // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
128 `Response status for procedure '${procedureName}' not supported: '${responsePayload.status}'`
129 )
130 )
131 }
132 this.responseHandlers.delete(uuid)
133 } else {
134 throw new Error(`Not a response to a request: ${JSON.stringify(response, undefined, 2)}`)
135 }
136 }
137
138 private async sendRequest (
139 procedureName: ProcedureName,
140 payload: RequestPayload
141 ): Promise<ResponsePayload> {
142 return new Promise<ResponsePayload>((resolve, reject) => {
143 if (this.ws?.readyState === WebSocket.OPEN) {
144 const uuid = randomUUID()
145 const msg = JSON.stringify([uuid, procedureName, payload])
146 const sendTimeout = setTimeout(() => {
147 this.responseHandlers.delete(uuid)
148 reject(new Error(`Send request '${procedureName}' message: connection timeout`))
149 }, 60000)
150 try {
151 this.ws.send(msg)
152 this.responseHandlers.set(uuid, { procedureName, reject, resolve })
153 } catch (error) {
154 this.responseHandlers.delete(uuid)
155 reject(
156 new Error(
157 `Send request '${procedureName}' message: error ${(error as Error).toString()}`
158 )
159 )
160 } finally {
161 clearTimeout(sendTimeout)
162 }
163 } else {
164 reject(new Error(`Send request '${procedureName}' message: connection closed`))
165 }
166 })
167 }
168
169 public async addChargingStations (
170 template: string,
171 numberOfStations: number,
172 options?: ChargingStationOptions
173 ): Promise<ResponsePayload> {
174 return this.sendRequest(ProcedureName.ADD_CHARGING_STATIONS, {
175 numberOfStations,
176 options,
177 template,
178 })
179 }
180
181 public async closeConnection (hashId: string): Promise<ResponsePayload> {
182 return this.sendRequest(ProcedureName.CLOSE_CONNECTION, {
183 hashIds: [hashId],
184 })
185 }
186
187 public async deleteChargingStation (hashId: string): Promise<ResponsePayload> {
188 return this.sendRequest(ProcedureName.DELETE_CHARGING_STATIONS, {
189 hashIds: [hashId],
190 })
191 }
192
193 public async listChargingStations (): Promise<ResponsePayload> {
194 return this.sendRequest(ProcedureName.LIST_CHARGING_STATIONS, {})
195 }
196
197 public async listTemplates (): Promise<ResponsePayload> {
198 return this.sendRequest(ProcedureName.LIST_TEMPLATES, {})
199 }
200
201 public async openConnection (hashId: string): Promise<ResponsePayload> {
202 return this.sendRequest(ProcedureName.OPEN_CONNECTION, {
203 hashIds: [hashId],
204 })
205 }
206
207 public registerWSEventListener<K extends keyof WebSocketEventMap>(
208 event: K,
209 listener: (event: WebSocketEventMap[K]) => void,
210 options?: AddEventListenerOptions | boolean
211 ) {
212 this.ws?.addEventListener(event, listener, options)
213 }
214
215 public setConfiguration (uiServerConfiguration: UIServerConfigurationSection): void {
216 if (this.ws?.readyState === WebSocket.OPEN) {
217 this.ws.close()
218 delete this.ws
219 }
220 this.uiServerConfiguration = uiServerConfiguration
221 this.openWS()
222 }
223
224 public async setSupervisionUrl (hashId: string, supervisionUrl: string): Promise<ResponsePayload> {
225 return this.sendRequest(ProcedureName.SET_SUPERVISION_URL, {
226 hashIds: [hashId],
227 url: supervisionUrl,
228 })
229 }
230
231 public async simulatorState (): Promise<ResponsePayload> {
232 return this.sendRequest(ProcedureName.SIMULATOR_STATE, {})
233 }
234
235 public async startAutomaticTransactionGenerator (
236 hashId: string,
237 connectorId: number
238 ): Promise<ResponsePayload> {
239 return this.sendRequest(ProcedureName.START_AUTOMATIC_TRANSACTION_GENERATOR, {
240 connectorIds: [connectorId],
241 hashIds: [hashId],
242 })
243 }
244
245 public async startChargingStation (hashId: string): Promise<ResponsePayload> {
246 return this.sendRequest(ProcedureName.START_CHARGING_STATION, {
247 hashIds: [hashId],
248 })
249 }
250
251 public async startSimulator (): Promise<ResponsePayload> {
252 return this.sendRequest(ProcedureName.START_SIMULATOR, {})
253 }
254
255 public async startTransaction (
256 hashId: string,
257 connectorId: number,
258 idTag: string | undefined
259 ): Promise<ResponsePayload> {
260 return this.sendRequest(ProcedureName.START_TRANSACTION, {
261 connectorId,
262 hashIds: [hashId],
263 idTag,
264 })
265 }
266
267 public async stopAutomaticTransactionGenerator (
268 hashId: string,
269 connectorId: number
270 ): Promise<ResponsePayload> {
271 return this.sendRequest(ProcedureName.STOP_AUTOMATIC_TRANSACTION_GENERATOR, {
272 connectorIds: [connectorId],
273 hashIds: [hashId],
274 })
275 }
276
277 public async stopChargingStation (hashId: string): Promise<ResponsePayload> {
278 return this.sendRequest(ProcedureName.STOP_CHARGING_STATION, {
279 hashIds: [hashId],
280 })
281 }
282
283 public async stopSimulator (): Promise<ResponsePayload> {
284 return this.sendRequest(ProcedureName.STOP_SIMULATOR, {})
285 }
286
287 public async stopTransaction (
288 hashId: string,
289 transactionId: number | undefined
290 ): Promise<ResponsePayload> {
291 return this.sendRequest(ProcedureName.STOP_TRANSACTION, {
292 hashIds: [hashId],
293 transactionId,
294 })
295 }
296
297 public unregisterWSEventListener<K extends keyof WebSocketEventMap>(
298 event: K,
299 listener: (event: WebSocketEventMap[K]) => void,
300 options?: AddEventListenerOptions | boolean
301 ) {
302 this.ws?.removeEventListener(event, listener, options)
303 }
304 }