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, { hashIds: [hashId] })
109 }
110
111 public async setSupervisionUrl(hashId: string, supervisionUrl: string): Promise<ResponsePayload> {
112 return this.sendRequest(ProcedureName.SET_SUPERVISION_URL, {
113 hashIds: [hashId],
114 url: supervisionUrl
115 })
116 }
117
118 public async startChargingStation(hashId: string): Promise<ResponsePayload> {
119 return this.sendRequest(ProcedureName.START_CHARGING_STATION, { hashIds: [hashId] })
120 }
121
122 public async stopChargingStation(hashId: string): Promise<ResponsePayload> {
123 return this.sendRequest(ProcedureName.STOP_CHARGING_STATION, { hashIds: [hashId] })
124 }
125
126 public async openConnection(hashId: string): Promise<ResponsePayload> {
127 return this.sendRequest(ProcedureName.OPEN_CONNECTION, {
128 hashIds: [hashId]
129 })
130 }
131
132 public async closeConnection(hashId: string): Promise<ResponsePayload> {
133 return this.sendRequest(ProcedureName.CLOSE_CONNECTION, {
134 hashIds: [hashId]
135 })
136 }
137
138 public async startTransaction(
139 hashId: string,
140 connectorId: number,
141 idTag: string | undefined
142 ): Promise<ResponsePayload> {
143 return this.sendRequest(ProcedureName.START_TRANSACTION, {
144 hashIds: [hashId],
145 connectorId,
146 idTag
147 })
148 }
149
150 public async stopTransaction(
151 hashId: string,
152 transactionId: number | undefined
153 ): Promise<ResponsePayload> {
154 return this.sendRequest(ProcedureName.STOP_TRANSACTION, {
155 hashIds: [hashId],
156 transactionId
157 })
158 }
159
160 public async startAutomaticTransactionGenerator(
161 hashId: string,
162 connectorId: number
163 ): Promise<ResponsePayload> {
164 return this.sendRequest(ProcedureName.START_AUTOMATIC_TRANSACTION_GENERATOR, {
165 hashIds: [hashId],
166 connectorIds: [connectorId]
167 })
168 }
169
170 public async stopAutomaticTransactionGenerator(
171 hashId: string,
172 connectorId: number
173 ): Promise<ResponsePayload> {
174 return this.sendRequest(ProcedureName.STOP_AUTOMATIC_TRANSACTION_GENERATOR, {
175 hashIds: [hashId],
176 connectorIds: [connectorId]
177 })
178 }
179
180 private openWS(): void {
181 const protocols =
182 this.uiServerConfiguration.authentication?.enabled === true &&
183 this.uiServerConfiguration.authentication?.type === AuthenticationType.PROTOCOL_BASIC_AUTH
184 ? [
185 `${this.uiServerConfiguration.protocol}${this.uiServerConfiguration.version}`,
186 `authorization.basic.${btoa(`${this.uiServerConfiguration.authentication.username}:${this.uiServerConfiguration.authentication.password}`).replace(/={1,2}$/, '')}`
187 ]
188 : `${this.uiServerConfiguration.protocol}${this.uiServerConfiguration.version}`
189 this.ws = new WebSocket(
190 `${this.uiServerConfiguration.secure === true ? ApplicationProtocol.WSS : ApplicationProtocol.WS}://${this.uiServerConfiguration.host}:${this.uiServerConfiguration.port}`,
191 protocols
192 )
193 this.ws.onopen = () => {
194 useToast().success(
195 `WebSocket to UI server '${this.uiServerConfiguration.host}' successfully opened`
196 )
197 }
198 this.ws.onmessage = this.responseHandler.bind(this)
199 this.ws.onerror = errorEvent => {
200 useToast().error(`Error in WebSocket to UI server '${this.uiServerConfiguration.host}'`)
201 console.error(
202 `Error in WebSocket to UI server '${this.uiServerConfiguration.host}'`,
203 errorEvent
204 )
205 }
206 this.ws.onclose = () => {
207 useToast().info(`WebSocket to UI server closed`)
208 }
209 }
210
211 private async sendRequest(
212 procedureName: ProcedureName,
213 payload: RequestPayload
214 ): Promise<ResponsePayload> {
215 return new Promise<ResponsePayload>((resolve, reject) => {
216 if (this.ws?.readyState === WebSocket.OPEN) {
217 const uuid = randomUUID()
218 const msg = JSON.stringify([uuid, procedureName, payload])
219 const sendTimeout = setTimeout(() => {
220 this.responseHandlers.delete(uuid)
221 return reject(new Error(`Send request '${procedureName}' message: connection timeout`))
222 }, 60000)
223 try {
224 this.ws.send(msg)
225 this.responseHandlers.set(uuid, { procedureName, resolve, reject })
226 } catch (error) {
227 this.responseHandlers.delete(uuid)
228 reject(error)
229 } finally {
230 clearTimeout(sendTimeout)
231 }
232 } else {
233 reject(new Error(`Send request '${procedureName}' message: connection closed`))
234 }
235 })
236 }
237
238 private responseHandler(messageEvent: MessageEvent<string>): void {
239 let response: ProtocolResponse
240 try {
241 response = JSON.parse(messageEvent.data)
242 } catch (error) {
243 useToast().error('Invalid response JSON format')
244 console.error('Invalid response JSON format', error)
245 return
246 }
247
248 if (!Array.isArray(response)) {
249 useToast().error('Response not an array')
250 console.error('Response not an array:', response)
251 return
252 }
253
254 const [uuid, responsePayload] = response
255
256 if (!validateUUID(uuid)) {
257 useToast().error('Response UUID field is invalid')
258 console.error('Response UUID field is invalid:', response)
259 return
260 }
261
262 if (this.responseHandlers.has(uuid)) {
263 const { procedureName, resolve, reject } = this.responseHandlers.get(uuid)!
264 switch (responsePayload.status) {
265 case ResponseStatus.SUCCESS:
266 resolve(responsePayload)
267 break
268 case ResponseStatus.FAILURE:
269 reject(responsePayload)
270 break
271 default:
272 reject(
273 new Error(
274 `Response status for procedure '${procedureName}' not supported: '${responsePayload.status}'`
275 )
276 )
277 }
278 this.responseHandlers.delete(uuid)
279 } else {
280 throw new Error(`Not a response to a request: ${JSON.stringify(response, undefined, 2)}`)
281 }
282 }
283 }