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