kirby-ocpp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/THIRD_PARTY_NOTICES.md +15 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +72 -0
  6. package/dist/config.d.ts +2 -0
  7. package/dist/config.js +53 -0
  8. package/dist/ocpp/client.d.ts +59 -0
  9. package/dist/ocpp/client.js +294 -0
  10. package/dist/ocpp/schema-validator.d.ts +15 -0
  11. package/dist/ocpp/schema-validator.js +75 -0
  12. package/dist/ocpp/schemas/json/Authorize.json +16 -0
  13. package/dist/ocpp/schemas/json/AuthorizeResponse.json +40 -0
  14. package/dist/ocpp/schemas/json/BootNotification.json +49 -0
  15. package/dist/ocpp/schemas/json/BootNotificationResponse.json +30 -0
  16. package/dist/ocpp/schemas/json/CancelReservation.json +15 -0
  17. package/dist/ocpp/schemas/json/CancelReservationResponse.json +20 -0
  18. package/dist/ocpp/schemas/json/ChangeAvailability.json +24 -0
  19. package/dist/ocpp/schemas/json/ChangeAvailabilityResponse.json +21 -0
  20. package/dist/ocpp/schemas/json/ChangeConfiguration.json +21 -0
  21. package/dist/ocpp/schemas/json/ChangeConfigurationResponse.json +22 -0
  22. package/dist/ocpp/schemas/json/ClearCache.json +8 -0
  23. package/dist/ocpp/schemas/json/ClearCacheResponse.json +20 -0
  24. package/dist/ocpp/schemas/json/ClearChargingProfile.json +27 -0
  25. package/dist/ocpp/schemas/json/ClearChargingProfileResponse.json +20 -0
  26. package/dist/ocpp/schemas/json/DataTransfer.json +23 -0
  27. package/dist/ocpp/schemas/json/DataTransferResponse.json +25 -0
  28. package/dist/ocpp/schemas/json/DiagnosticsStatusNotification.json +22 -0
  29. package/dist/ocpp/schemas/json/DiagnosticsStatusNotificationResponse.json +8 -0
  30. package/dist/ocpp/schemas/json/FirmwareStatusNotification.json +25 -0
  31. package/dist/ocpp/schemas/json/FirmwareStatusNotificationResponse.json +8 -0
  32. package/dist/ocpp/schemas/json/GetCompositeSchedule.json +27 -0
  33. package/dist/ocpp/schemas/json/GetCompositeScheduleResponse.json +79 -0
  34. package/dist/ocpp/schemas/json/GetConfiguration.json +16 -0
  35. package/dist/ocpp/schemas/json/GetConfigurationResponse.json +40 -0
  36. package/dist/ocpp/schemas/json/GetDiagnostics.json +30 -0
  37. package/dist/ocpp/schemas/json/GetDiagnosticsResponse.json +13 -0
  38. package/dist/ocpp/schemas/json/GetLocalListVersion.json +8 -0
  39. package/dist/ocpp/schemas/json/GetLocalListVersionResponse.json +15 -0
  40. package/dist/ocpp/schemas/json/Heartbeat.json +8 -0
  41. package/dist/ocpp/schemas/json/HeartbeatResponse.json +16 -0
  42. package/dist/ocpp/schemas/json/MeterValues.json +151 -0
  43. package/dist/ocpp/schemas/json/MeterValuesResponse.json +8 -0
  44. package/dist/ocpp/schemas/json/RemoteStartTransaction.json +127 -0
  45. package/dist/ocpp/schemas/json/RemoteStartTransactionResponse.json +20 -0
  46. package/dist/ocpp/schemas/json/RemoteStopTransaction.json +15 -0
  47. package/dist/ocpp/schemas/json/RemoteStopTransactionResponse.json +20 -0
  48. package/dist/ocpp/schemas/json/ReserveNow.json +33 -0
  49. package/dist/ocpp/schemas/json/ReserveNowResponse.json +23 -0
  50. package/dist/ocpp/schemas/json/Reset.json +20 -0
  51. package/dist/ocpp/schemas/json/ResetResponse.json +20 -0
  52. package/dist/ocpp/schemas/json/SendLocalList.json +68 -0
  53. package/dist/ocpp/schemas/json/SendLocalListResponse.json +22 -0
  54. package/dist/ocpp/schemas/json/SetChargingProfile.json +124 -0
  55. package/dist/ocpp/schemas/json/SetChargingProfileResponse.json +21 -0
  56. package/dist/ocpp/schemas/json/StartTransaction.json +32 -0
  57. package/dist/ocpp/schemas/json/StartTransactionResponse.json +44 -0
  58. package/dist/ocpp/schemas/json/StatusNotification.json +70 -0
  59. package/dist/ocpp/schemas/json/StatusNotificationResponse.json +8 -0
  60. package/dist/ocpp/schemas/json/StopTransaction.json +177 -0
  61. package/dist/ocpp/schemas/json/StopTransactionResponse.json +37 -0
  62. package/dist/ocpp/schemas/json/TriggerMessage.json +27 -0
  63. package/dist/ocpp/schemas/json/TriggerMessageResponse.json +21 -0
  64. package/dist/ocpp/schemas/json/UnlockConnector.json +15 -0
  65. package/dist/ocpp/schemas/json/UnlockConnectorResponse.json +21 -0
  66. package/dist/ocpp/schemas/json/UpdateFirmware.json +27 -0
  67. package/dist/ocpp/schemas/json/UpdateFirmwareResponse.json +8 -0
  68. package/dist/ocpp/types.d.ts +106 -0
  69. package/dist/ocpp/types.js +1 -0
  70. package/dist/station/authorization.d.ts +20 -0
  71. package/dist/station/authorization.js +78 -0
  72. package/dist/station/charging-profile-registry.d.ts +18 -0
  73. package/dist/station/charging-profile-registry.js +125 -0
  74. package/dist/station/configuration-commands.d.ts +11 -0
  75. package/dist/station/configuration-commands.js +29 -0
  76. package/dist/station/configuration-registry.d.ts +13 -0
  77. package/dist/station/configuration-registry.js +39 -0
  78. package/dist/station/configuration.d.ts +7 -0
  79. package/dist/station/configuration.js +88 -0
  80. package/dist/station/connector-registry.d.ts +25 -0
  81. package/dist/station/connector-registry.js +89 -0
  82. package/dist/station/connector-status.d.ts +7 -0
  83. package/dist/station/connector-status.js +66 -0
  84. package/dist/station/local-auth-list.d.ts +8 -0
  85. package/dist/station/local-auth-list.js +31 -0
  86. package/dist/station/maintenance-lifecycle.d.ts +25 -0
  87. package/dist/station/maintenance-lifecycle.js +110 -0
  88. package/dist/station/meter-value-scheduler.d.ts +21 -0
  89. package/dist/station/meter-value-scheduler.js +78 -0
  90. package/dist/station/metering.d.ts +15 -0
  91. package/dist/station/metering.js +138 -0
  92. package/dist/station/ocpp-message.d.ts +2 -0
  93. package/dist/station/ocpp-message.js +9 -0
  94. package/dist/station/offline-transaction-sync.d.ts +29 -0
  95. package/dist/station/offline-transaction-sync.js +99 -0
  96. package/dist/station/payload.d.ts +4 -0
  97. package/dist/station/payload.js +12 -0
  98. package/dist/station/persistence.d.ts +28 -0
  99. package/dist/station/persistence.js +206 -0
  100. package/dist/station/reservation-rules.d.ts +3 -0
  101. package/dist/station/reservation-rules.js +18 -0
  102. package/dist/station/reservation-scheduler.d.ts +11 -0
  103. package/dist/station/reservation-scheduler.js +33 -0
  104. package/dist/station/smart-charging.d.ts +45 -0
  105. package/dist/station/smart-charging.js +251 -0
  106. package/dist/station/status-scheduler.d.ts +16 -0
  107. package/dist/station/status-scheduler.js +58 -0
  108. package/dist/station/transaction-message-retry.d.ts +3 -0
  109. package/dist/station/transaction-message-retry.js +7 -0
  110. package/dist/station/transaction-rules.d.ts +7 -0
  111. package/dist/station/transaction-rules.js +34 -0
  112. package/dist/station/trigger-message.d.ts +2 -0
  113. package/dist/station/trigger-message.js +11 -0
  114. package/dist/station-store.d.ts +41 -0
  115. package/dist/station-store.js +104 -0
  116. package/dist/station.d.ts +112 -0
  117. package/dist/station.js +1201 -0
  118. package/dist/tui.d.ts +95 -0
  119. package/dist/tui.js +1601 -0
  120. package/docs/OCPP_1.6J_CONFORMANCE.md +61 -0
  121. package/package.json +62 -0
@@ -0,0 +1,1201 @@
1
+ import EventEmitter from "node:events";
2
+ import { OcppClient } from "./ocpp/client.js";
3
+ import { StationStore } from "./station-store.js";
4
+ import { authorizeLocally, isAcceptedIdTagInfo, normalizeIdTagInfo, serializeIdTagInfo } from "./station/authorization.js";
5
+ import { faultErrorCode, nextConnectorStatus, statusAfterAvailabilityChange, statusAfterFaultClear, statusAfterReservationClear } from "./station/connector-status.js";
6
+ import { ConnectorRegistry } from "./station/connector-registry.js";
7
+ import { ChargingProfileRegistry } from "./station/charging-profile-registry.js";
8
+ import { changeConfiguration, getConfiguration } from "./station/configuration-commands.js";
9
+ import { ConfigurationRegistry } from "./station/configuration-registry.js";
10
+ import { buildMeterValue, isValidSampledDataConfiguration, readConfiguredMeasurands } from "./station/metering.js";
11
+ import { MeterValueScheduler } from "./station/meter-value-scheduler.js";
12
+ import { ReservationScheduler } from "./station/reservation-scheduler.js";
13
+ import { buildPersistedStationState, restorePersistedStationState } from "./station/persistence.js";
14
+ import { readNumber, readObject, readOptionalString, readString } from "./station/payload.js";
15
+ import { applyLocalAuthListUpdate } from "./station/local-auth-list.js";
16
+ import { MaintenanceLifecycle } from "./station/maintenance-lifecycle.js";
17
+ import { describeOcppMessage } from "./station/ocpp-message.js";
18
+ import { StatusNotificationScheduler } from "./station/status-scheduler.js";
19
+ import { canTriggerMessage } from "./station/trigger-message.js";
20
+ import { reserveNowRejection } from "./station/reservation-rules.js";
21
+ import { canRemoteStart, startTransactionRejection, statusAfterStopTransaction } from "./station/transaction-rules.js";
22
+ import { readChargingProfile } from "./station/smart-charging.js";
23
+ import { isTransactionMessage, retryDelay } from "./station/transaction-message-retry.js";
24
+ import { localTransactionId, OfflineTransactionSync, pendingStopPayload } from "./station/offline-transaction-sync.js";
25
+ export class Station extends EventEmitter {
26
+ config;
27
+ client;
28
+ state = {
29
+ connected: false,
30
+ booted: false,
31
+ registrationStatus: "Unknown",
32
+ connectorStatus: "Available",
33
+ availability: "Operative",
34
+ localListVersion: 0,
35
+ meterWh: 0,
36
+ connectors: [],
37
+ diagnosticsStatus: "Idle",
38
+ firmwareStatus: "Idle"
39
+ };
40
+ heartbeatTimer;
41
+ resetTimer;
42
+ bootRetryTimer;
43
+ reconnectTimer;
44
+ reconnectAttempts = 0;
45
+ manuallyDisconnected = true;
46
+ store;
47
+ configuration;
48
+ localAuthorizationList = new Map();
49
+ authorizationCache = new Map();
50
+ connectors;
51
+ statusScheduler;
52
+ meterValueScheduler;
53
+ reservationScheduler;
54
+ maintenance;
55
+ chargingProfiles = new ChargingProfileRegistry();
56
+ offlineTransactions;
57
+ constructor(config) {
58
+ super();
59
+ this.config = config;
60
+ this.client = new OcppClient(config.centralSystemUrl, config.chargePointId, {
61
+ subprotocol: config.webSocketSubprotocol,
62
+ pingIntervalSeconds: config.webSocketPingIntervalSeconds,
63
+ tlsRejectUnauthorized: config.tlsRejectUnauthorized,
64
+ tlsCaFile: config.tlsCaFile,
65
+ tlsCertFile: config.tlsCertFile,
66
+ tlsKeyFile: config.tlsKeyFile,
67
+ tlsServerName: config.tlsServerName
68
+ });
69
+ this.configuration = new ConfigurationRegistry(config);
70
+ this.connectors = new ConnectorRegistry(config.connectorId, config.connectorCount);
71
+ this.connectors.setOnChange(() => this.refreshConnectorSnapshot());
72
+ this.configuration.setValue("NumberOfConnectors", String(this.connectors.size));
73
+ this.statusScheduler = new StatusNotificationScheduler({
74
+ minimumSeconds: () => this.configuration.integer("MinimumStatusDuration", 0),
75
+ currentStatus: (connectorId) => this.connectors.find(connectorId)?.status,
76
+ send: (connectorId, status) => void this.runSafely(() => this.sendStatusNotification(connectorId, status))
77
+ });
78
+ this.meterValueScheduler = new MeterValueScheduler({
79
+ sampleIntervalSeconds: () => this.configuration.integer("MeterValueSampleInterval", 0),
80
+ clockAlignedIntervalSeconds: () => this.configuration.integer("ClockAlignedDataInterval", 0),
81
+ isConnected: () => this.state.connected,
82
+ activeConnectorIds: () => this.connectors.all()
83
+ .filter((connector) => connector.transactionId && connector.status === "Charging")
84
+ .map((connector) => connector.id),
85
+ isPeriodicConnectorActive: (connectorId) => {
86
+ const connector = this.connectors.find(connectorId);
87
+ return Boolean(connector?.transactionId && connector.status === "Charging");
88
+ },
89
+ sendPeriodic: (connectorId, deltaWh) => this.sendMeterValues(connectorId, "Sample.Periodic", deltaWh, this.configuration.measurands("MeterValuesSampledData")),
90
+ sendClockAligned: (connectorIds, deltaWh) => this.sendClockAlignedMeterValues(connectorIds, deltaWh),
91
+ runSafely: (task) => void this.runSafely(task)
92
+ });
93
+ this.reservationScheduler = new ReservationScheduler({
94
+ expiryDate: (connectorId) => this.connectors.find(connectorId)?.reservationExpiryDate,
95
+ expire: (connectorId) => this.expireReservation(connectorId)
96
+ });
97
+ this.maintenance = new MaintenanceLifecycle({
98
+ run: (task) => void this.runSafely(task),
99
+ diagnosticsStatus: (status) => this.diagnosticsStatusNotification(status),
100
+ firmwareStatus: (status) => this.firmwareStatusNotification(status),
101
+ log: (level, message) => this.log(level, message)
102
+ });
103
+ this.offlineTransactions = new OfflineTransactionSync({
104
+ connectors: this.connectors,
105
+ chargingProfiles: this.chargingProfiles,
106
+ send: (action, payload) => this.send(action, payload),
107
+ readIdTagInfo: (value) => this.readIdTagInfo(value),
108
+ rememberAuthorization: (idTag, idTagInfo) => this.rememberAuthorization(idTag, idTagInfo),
109
+ save: () => this.savePersistedState(),
110
+ log: (level, message) => this.log(level, message),
111
+ statusNotification: (connectorId, status) => this.statusNotification(connectorId, status)
112
+ });
113
+ this.store = config.persistState ? new StationStore(config.chargePointId, config.stateDirectory) : undefined;
114
+ this.loadPersistedState();
115
+ this.refreshConnectorSnapshot();
116
+ this.bindClientEvents();
117
+ }
118
+ connect() {
119
+ this.manuallyDisconnected = false;
120
+ this.clearReconnectTimer();
121
+ this.log("info", `Connecting to ${this.config.centralSystemUrl}`);
122
+ this.client.connect();
123
+ }
124
+ disconnect() {
125
+ this.manuallyDisconnected = true;
126
+ this.clearReconnectTimer();
127
+ this.stopHeartbeatTimer();
128
+ this.clearResetTimer();
129
+ this.clearBootRetryTimer();
130
+ this.meterValueScheduler.stopAllPeriodic();
131
+ this.meterValueScheduler.stopClockAligned();
132
+ this.reservationScheduler.clearAll();
133
+ this.statusScheduler.clearAll();
134
+ this.maintenance.clearAll();
135
+ this.client.close();
136
+ this.patchState({ connected: false });
137
+ }
138
+ reconnect() {
139
+ this.disconnect();
140
+ this.connect();
141
+ }
142
+ async boot(options = {}) {
143
+ const scheduleRetry = options.scheduleRetry ?? true;
144
+ this.clearBootRetryTimer();
145
+ const response = await this.send("BootNotification", {
146
+ chargePointVendor: this.config.vendor,
147
+ chargePointModel: this.config.model
148
+ });
149
+ const status = readString(response.payload.status, "Unknown");
150
+ const interval = readNumber(response.payload.interval, this.config.heartbeatIntervalSeconds);
151
+ this.patchState({
152
+ booted: status === "Accepted",
153
+ registrationStatus: status
154
+ });
155
+ if (status === "Accepted") {
156
+ this.reconnectAttempts = 0;
157
+ this.configuration.setValue("HeartbeatInterval", String(interval));
158
+ this.savePersistedState();
159
+ this.startHeartbeatTimer(interval);
160
+ await this.offlineTransactions.syncPendingTransactions();
161
+ this.meterValueScheduler.reschedulePeriodic(this.connectors.all().map((connector) => connector.id));
162
+ this.meterValueScheduler.startClockAligned();
163
+ await Promise.all(this.connectors.all().map((connector) => this.statusNotification(connector.id, connector.status, true)));
164
+ return;
165
+ }
166
+ this.stopHeartbeatTimer();
167
+ if (scheduleRetry) {
168
+ this.scheduleBootRetry(interval);
169
+ }
170
+ }
171
+ async heartbeat() {
172
+ const response = await this.send("Heartbeat", {});
173
+ this.patchState({ lastHeartbeatAt: new Date() });
174
+ if (response.payload.currentTime) {
175
+ this.log("success", `Heartbeat accepted, Central System time: ${String(response.payload.currentTime)}`);
176
+ }
177
+ }
178
+ async statusNotification(connectorId = this.config.connectorId, status = this.connectors.get(connectorId).status, force = false) {
179
+ if (!this.client.isConnected) {
180
+ return;
181
+ }
182
+ if (!force && this.statusScheduler.schedule(connectorId, status)) {
183
+ return;
184
+ }
185
+ this.statusScheduler.clear(connectorId);
186
+ await this.sendStatusNotification(connectorId, status);
187
+ }
188
+ async sendStatusNotification(connectorId, status) {
189
+ const connector = this.connectors.get(connectorId);
190
+ const payload = {
191
+ connectorId,
192
+ errorCode: status === "Faulted" ? faultErrorCode(connector) : "NoError",
193
+ status,
194
+ timestamp: new Date().toISOString()
195
+ };
196
+ if (connector.info) {
197
+ payload.info = connector.info;
198
+ }
199
+ if (connector.vendorId) {
200
+ payload.vendorId = connector.vendorId;
201
+ }
202
+ if (connector.vendorErrorCode) {
203
+ payload.vendorErrorCode = connector.vendorErrorCode;
204
+ }
205
+ await this.send("StatusNotification", payload);
206
+ this.statusScheduler.markSent(connectorId, status);
207
+ }
208
+ async authorize(idTag) {
209
+ if (!idTag) {
210
+ this.log("warn", "Authorize requires idTag");
211
+ return { accepted: false, idTagInfo: { status: "Invalid" }, source: "None" };
212
+ }
213
+ if (this.client.isConnected) {
214
+ const response = await this.send("Authorize", { idTag });
215
+ const idTagInfo = this.readIdTagInfo(response.payload.idTagInfo);
216
+ this.rememberAuthorization(idTag, idTagInfo);
217
+ const accepted = isAcceptedIdTagInfo(idTagInfo);
218
+ this.log(accepted ? "success" : "warn", `Authorization status: ${idTagInfo.status} (${idTag})`);
219
+ if (!accepted) {
220
+ await this.stopTransactionsForInvalidIdTag(idTag);
221
+ }
222
+ return { accepted, idTagInfo, source: "CentralSystem" };
223
+ }
224
+ const decision = this.authorizeLocally(idTag, true);
225
+ this.log(decision.accepted ? "success" : "warn", `Offline authorization status: ${decision.idTagInfo.status} (${idTag}, ${decision.source})`);
226
+ return decision;
227
+ }
228
+ async toggleTransaction(connectorId = this.config.connectorId, idTag) {
229
+ if (!idTag) {
230
+ this.log("warn", "StartTransaction requires idTag");
231
+ return;
232
+ }
233
+ const connector = this.connectors.get(connectorId);
234
+ if (connector.transactionId) {
235
+ await this.stopTransaction(connectorId, "Local", idTag);
236
+ return;
237
+ }
238
+ await this.startTransaction(connectorId, idTag);
239
+ }
240
+ async startTransaction(connectorId = this.config.connectorId, idTag) {
241
+ if (!idTag) {
242
+ this.log("warn", "StartTransaction requires idTag");
243
+ return undefined;
244
+ }
245
+ const connector = this.connectors.get(connectorId);
246
+ const rejection = startTransactionRejection(connector, idTag);
247
+ if (rejection) {
248
+ this.log("warn", rejection);
249
+ return undefined;
250
+ }
251
+ if (!connector.evConnected && connector.status === "Available") {
252
+ this.connectors.patch(connectorId, { evConnected: true, status: "Preparing" });
253
+ void this.runSafely(() => this.statusNotification(connectorId, "Preparing"));
254
+ }
255
+ else if (connector.status === "Available") {
256
+ this.connectors.patch(connectorId, { status: "Preparing" });
257
+ void this.runSafely(() => this.statusNotification(connectorId, "Preparing"));
258
+ }
259
+ const payload = {
260
+ connectorId,
261
+ idTag,
262
+ meterStart: Math.round(connector.meterWh),
263
+ timestamp: new Date().toISOString()
264
+ };
265
+ if (connector.reservationId) {
266
+ payload.reservationId = connector.reservationId;
267
+ }
268
+ const localPreAuthorize = this.configuration.boolean("LocalPreAuthorize", false);
269
+ if (localPreAuthorize) {
270
+ const decision = this.authorizeLocally(idTag, false);
271
+ if (!decision.accepted) {
272
+ this.log("warn", `StartTransaction blocked by local authorization: ${decision.idTagInfo.status} (${idTag})`);
273
+ return undefined;
274
+ }
275
+ }
276
+ if (!this.client.isConnected) {
277
+ const decision = this.authorizeLocally(idTag, true);
278
+ if (!decision.accepted) {
279
+ this.log("warn", `Offline StartTransaction blocked: ${decision.idTagInfo.status} (${idTag}, ${decision.source})`);
280
+ return undefined;
281
+ }
282
+ const transactionId = localTransactionId(connectorId);
283
+ this.reservationScheduler.clear(connectorId);
284
+ this.connectors.patch(connectorId, {
285
+ transactionId,
286
+ transactionStartedAt: readString(payload.timestamp, new Date().toISOString()),
287
+ reservationId: undefined,
288
+ reservationIdTag: undefined,
289
+ reservationParentIdTag: undefined,
290
+ reservationExpiryDate: undefined,
291
+ lastIdTag: idTag,
292
+ stopTransactionAtWh: undefined,
293
+ pendingStartTransaction: payload,
294
+ pendingStopTransaction: undefined,
295
+ status: "Charging"
296
+ });
297
+ this.savePersistedState();
298
+ this.meterValueScheduler.startPeriodic(connectorId);
299
+ this.log("warn", `Started local offline transaction ${transactionId} on connector ${connectorId}; will sync after reconnect`);
300
+ void this.runSafely(() => this.statusNotification(connectorId, "Charging"));
301
+ return transactionId;
302
+ }
303
+ const response = await this.send("StartTransaction", payload);
304
+ const idTagInfo = this.readIdTagInfo(response.payload.idTagInfo);
305
+ this.rememberAuthorization(idTag, idTagInfo);
306
+ if (!isAcceptedIdTagInfo(idTagInfo)) {
307
+ this.log("warn", `StartTransaction rejected by Central System: ${idTagInfo.status} (${idTag})`);
308
+ return undefined;
309
+ }
310
+ const transactionId = readNumber(response.payload.transactionId, Date.now());
311
+ this.reservationScheduler.clear(connectorId);
312
+ this.connectors.patch(connectorId, {
313
+ transactionId,
314
+ transactionStartedAt: new Date().toISOString(),
315
+ reservationId: undefined,
316
+ reservationIdTag: undefined,
317
+ reservationParentIdTag: undefined,
318
+ reservationExpiryDate: undefined,
319
+ lastIdTag: idTag,
320
+ stopTransactionAtWh: undefined,
321
+ pendingStartTransaction: undefined,
322
+ pendingStopTransaction: undefined,
323
+ status: "Charging"
324
+ });
325
+ this.savePersistedState();
326
+ this.meterValueScheduler.startPeriodic(connectorId);
327
+ this.meterValueScheduler.startClockAligned();
328
+ void this.runSafely(() => this.statusNotification(connectorId, "Charging"));
329
+ return transactionId;
330
+ }
331
+ async stopTransaction(connectorId = this.config.connectorId, reason = "Local", idTag) {
332
+ const connector = this.connectors.get(connectorId);
333
+ if (!connector.transactionId) {
334
+ this.log("warn", `Connector ${connectorId} has no active transaction`);
335
+ return;
336
+ }
337
+ this.meterValueScheduler.stopPeriodic(connectorId);
338
+ const transactionId = connector.transactionId;
339
+ const pendingStop = {
340
+ meterStop: Math.round(connector.meterWh),
341
+ timestamp: new Date().toISOString(),
342
+ reason,
343
+ transactionData: this.buildStopTransactionData(connectorId)
344
+ };
345
+ if (idTag) {
346
+ pendingStop.idTag = idTag;
347
+ }
348
+ if (!this.client.isConnected || connector.pendingStartTransaction) {
349
+ const { evConnected, status } = statusAfterStopTransaction(reason, connector.evConnected, connector.availability);
350
+ this.connectors.patch(connectorId, {
351
+ pendingStopTransaction: pendingStop,
352
+ evConnected,
353
+ status
354
+ });
355
+ this.savePersistedState();
356
+ this.log("warn", `Queued ${connector.pendingStartTransaction ? "offline" : "disconnected"} StopTransaction for connector ${connectorId}; will sync after reconnect`);
357
+ void this.runSafely(() => this.statusNotification(connectorId, status));
358
+ return;
359
+ }
360
+ const payload = { ...pendingStopPayload(pendingStop), transactionId };
361
+ this.connectors.patch(connectorId, { status: "Finishing" });
362
+ void this.runSafely(() => this.statusNotification(connectorId, "Finishing"));
363
+ try {
364
+ await this.send("StopTransaction", payload);
365
+ }
366
+ catch (error) {
367
+ const restoredStatus = connector.evConnected ? "Charging" : "SuspendedEV";
368
+ this.connectors.patch(connectorId, { status: restoredStatus });
369
+ if (restoredStatus === "Charging") {
370
+ this.meterValueScheduler.startPeriodic(connectorId);
371
+ this.meterValueScheduler.startClockAligned();
372
+ }
373
+ void this.runSafely(() => this.statusNotification(connectorId, restoredStatus));
374
+ throw error;
375
+ }
376
+ const { evConnected, status } = statusAfterStopTransaction(reason, connector.evConnected, connector.availability);
377
+ this.connectors.patch(connectorId, {
378
+ transactionId: undefined,
379
+ transactionStartedAt: undefined,
380
+ stopTransactionAtWh: undefined,
381
+ pendingStartTransaction: undefined,
382
+ pendingStopTransaction: undefined,
383
+ evConnected,
384
+ status
385
+ });
386
+ this.chargingProfiles.clearForTransaction(transactionId);
387
+ this.savePersistedState();
388
+ if (status !== "Finishing") {
389
+ void this.runSafely(() => this.statusNotification(connectorId, status));
390
+ }
391
+ }
392
+ async plugIn(connectorId = this.config.connectorId) {
393
+ const connector = this.connectors.get(connectorId);
394
+ if (connector.evConnected) {
395
+ this.log("warn", `Connector ${connectorId} already has EV connected`);
396
+ return;
397
+ }
398
+ if (connector.availability === "Inoperative" || connector.status === "Unavailable" || connector.status === "Faulted") {
399
+ this.log("warn", `Connector ${connectorId} cannot accept EV while ${connector.status}`);
400
+ return;
401
+ }
402
+ if (connector.transactionId) {
403
+ this.connectors.patch(connectorId, { evConnected: true, status: "Charging" });
404
+ this.meterValueScheduler.startPeriodic(connectorId);
405
+ this.meterValueScheduler.startClockAligned();
406
+ await this.statusNotification(connectorId, "Charging");
407
+ return;
408
+ }
409
+ const status = connector.reservationId ? "Reserved" : "Preparing";
410
+ this.connectors.patch(connectorId, { evConnected: true, status });
411
+ await this.statusNotification(connectorId, status);
412
+ }
413
+ async unplug(connectorId = this.config.connectorId) {
414
+ const connector = this.connectors.get(connectorId);
415
+ if (connector.transactionId) {
416
+ if (!this.configuration.boolean("StopTransactionOnEVSideDisconnect", true)) {
417
+ this.meterValueScheduler.stopPeriodic(connectorId);
418
+ this.connectors.patch(connectorId, { evConnected: false, status: "SuspendedEV" });
419
+ await this.statusNotification(connectorId, "SuspendedEV");
420
+ return;
421
+ }
422
+ await this.stopTransaction(connectorId, "EVDisconnected", connector.lastIdTag);
423
+ return;
424
+ }
425
+ if (!connector.evConnected && connector.status !== "Finishing") {
426
+ this.log("warn", `Connector ${connectorId} has no EV connected`);
427
+ return;
428
+ }
429
+ const status = connector.availability === "Inoperative" || connector.status === "Unavailable" ? "Unavailable" : "Available";
430
+ this.connectors.patch(connectorId, { evConnected: false, status });
431
+ await this.statusNotification(connectorId, status);
432
+ }
433
+ async setFault(connectorId = this.config.connectorId, errorCode = "OtherError", info, vendorErrorCode) {
434
+ const connector = this.connectors.get(connectorId);
435
+ if (connector.transactionId) {
436
+ await this.stopTransaction(connectorId, "Other", connector.lastIdTag);
437
+ }
438
+ this.connectors.patch(connectorId, {
439
+ status: "Faulted",
440
+ errorCode: errorCode === "NoError" ? "OtherError" : errorCode,
441
+ info: trimOptional(info, 50),
442
+ vendorId: this.config.vendor,
443
+ vendorErrorCode: trimOptional(vendorErrorCode, 50)
444
+ });
445
+ await this.statusNotification(connectorId, "Faulted");
446
+ }
447
+ async clearFault(connectorId = this.config.connectorId) {
448
+ const connector = this.connectors.get(connectorId);
449
+ const status = statusAfterFaultClear(connector);
450
+ this.connectors.patch(connectorId, {
451
+ status,
452
+ errorCode: "NoError",
453
+ info: undefined,
454
+ vendorId: undefined,
455
+ vendorErrorCode: undefined
456
+ });
457
+ await this.statusNotification(connectorId, status);
458
+ }
459
+ async meterValues(connectorId = this.config.connectorId, deltaWh = 120, sampledData) {
460
+ if (sampledData !== undefined && !isValidSampledDataConfiguration(sampledData)) {
461
+ this.log("warn", `Invalid MeterValues sampled data: ${sampledData}`);
462
+ return;
463
+ }
464
+ const connector = this.connectors.get(connectorId);
465
+ const measurands = sampledData === undefined ? this.configuration.measurands("MeterValuesSampledData") : readConfiguredMeasurands(sampledData);
466
+ if (!connector) {
467
+ return;
468
+ }
469
+ await this.sendMeterValues(connectorId, "Sample.Periodic", deltaWh, measurands);
470
+ }
471
+ async dataTransfer(payload = { vendorId: this.config.vendor }) {
472
+ const response = await this.send("DataTransfer", payload);
473
+ const status = readString(response.payload.status, "Unknown");
474
+ this.log(status === "Accepted" ? "success" : "warn", `DataTransfer status: ${status}`);
475
+ if (response.payload.data) {
476
+ this.log("info", `DataTransfer response data: ${String(response.payload.data)}`);
477
+ }
478
+ }
479
+ async firmwareStatusNotification(status = "Installed") {
480
+ this.patchState({ firmwareStatus: status });
481
+ await this.send("FirmwareStatusNotification", { status });
482
+ }
483
+ async diagnosticsStatusNotification(status = "Uploaded") {
484
+ this.patchState({ diagnosticsStatus: status });
485
+ await this.send("DiagnosticsStatusNotification", { status });
486
+ }
487
+ setDiagnosticsOutcome(outcome) {
488
+ this.maintenance.setDiagnosticsOutcome(outcome);
489
+ this.log("info", `Diagnostics outcome set to ${outcome}`);
490
+ }
491
+ setFirmwareOutcome(outcome) {
492
+ this.maintenance.setFirmwareOutcome(outcome);
493
+ this.log("info", `Firmware outcome set to ${outcome}`);
494
+ }
495
+ async cycleStatus(connectorId = this.config.connectorId) {
496
+ const connector = this.connectors.get(connectorId);
497
+ const next = nextConnectorStatus(connector.status);
498
+ this.connectors.patch(connectorId, {
499
+ status: next,
500
+ errorCode: next === "Faulted" ? "OtherError" : "NoError",
501
+ info: next === "Faulted" ? "Manual fault" : undefined,
502
+ vendorId: next === "Faulted" ? this.config.vendor : undefined,
503
+ vendorErrorCode: undefined
504
+ });
505
+ await this.statusNotification(connectorId, next);
506
+ }
507
+ addConnector() {
508
+ const { id: nextId } = this.connectors.add();
509
+ this.configuration.setValue("NumberOfConnectors", String(this.connectors.size));
510
+ this.savePersistedState();
511
+ void this.runSafely(() => this.statusNotification(nextId, "Available"));
512
+ return nextId;
513
+ }
514
+ bindClientEvents() {
515
+ this.client.on("connected", () => {
516
+ this.clearReconnectTimer();
517
+ this.patchState({ connected: true });
518
+ this.log("success", "Connected");
519
+ void this.runSafely(() => this.boot());
520
+ });
521
+ this.client.on("disconnected", (code, reason) => {
522
+ this.stopHeartbeatTimer();
523
+ this.clearResetTimer();
524
+ this.clearBootRetryTimer();
525
+ this.patchState({ connected: false, booted: false });
526
+ this.log("warn", `Disconnected${code ? ` (${code})` : ""}${reason ? `: ${reason}` : ""}`);
527
+ this.scheduleReconnect();
528
+ });
529
+ this.client.on("error", (error) => this.log("error", error.message));
530
+ this.client.on("log", (direction, message) => {
531
+ this.log(direction, describeOcppMessage(message), JSON.stringify(message));
532
+ });
533
+ this.client.on("call", (messageId, action, payload) => {
534
+ void this.handleCentralSystemCall(messageId, action, payload);
535
+ });
536
+ }
537
+ async handleCentralSystemCall(messageId, action, payload) {
538
+ try {
539
+ switch (action) {
540
+ case "CancelReservation":
541
+ this.handleCancelReservation(messageId, payload);
542
+ break;
543
+ case "ChangeAvailability":
544
+ await this.handleChangeAvailability(messageId, payload);
545
+ break;
546
+ case "ChangeConfiguration":
547
+ this.handleChangeConfiguration(messageId, payload);
548
+ break;
549
+ case "ClearCache":
550
+ this.authorizationCache.clear();
551
+ this.savePersistedState();
552
+ this.client.reply(messageId, { status: "Accepted" });
553
+ break;
554
+ case "ClearChargingProfile":
555
+ this.handleClearChargingProfile(messageId, payload);
556
+ break;
557
+ case "DataTransfer":
558
+ this.handleDataTransfer(messageId, payload);
559
+ break;
560
+ case "GetCompositeSchedule":
561
+ this.handleGetCompositeSchedule(messageId, payload);
562
+ break;
563
+ case "GetConfiguration":
564
+ this.handleGetConfiguration(messageId, payload);
565
+ break;
566
+ case "GetDiagnostics":
567
+ this.client.reply(messageId, { fileName: this.maintenance.startDiagnostics(payload) });
568
+ break;
569
+ case "GetLocalListVersion":
570
+ this.client.reply(messageId, { listVersion: this.state.localListVersion });
571
+ break;
572
+ case "RemoteStartTransaction":
573
+ await this.handleRemoteStartTransaction(messageId, payload);
574
+ break;
575
+ case "RemoteStopTransaction":
576
+ await this.handleRemoteStopTransaction(messageId, payload);
577
+ break;
578
+ case "ReserveNow":
579
+ await this.handleReserveNow(messageId, payload);
580
+ break;
581
+ case "Reset":
582
+ this.handleReset(messageId, payload);
583
+ break;
584
+ case "SendLocalList":
585
+ this.handleSendLocalList(messageId, payload);
586
+ break;
587
+ case "SetChargingProfile":
588
+ this.handleSetChargingProfile(messageId, payload);
589
+ break;
590
+ case "TriggerMessage":
591
+ await this.handleTriggerMessage(messageId, payload);
592
+ break;
593
+ case "UnlockConnector":
594
+ await this.handleUnlockConnector(messageId, payload);
595
+ break;
596
+ case "UpdateFirmware":
597
+ this.client.reply(messageId, {});
598
+ this.maintenance.startFirmware(payload);
599
+ break;
600
+ default:
601
+ this.client.replyError(messageId, "NotSupported", `${action} is not supported by this emulator`);
602
+ }
603
+ }
604
+ catch (error) {
605
+ const message = error instanceof Error ? error.message : String(error);
606
+ this.client.replyError(messageId, "InternalError", message);
607
+ }
608
+ }
609
+ handleCancelReservation(messageId, payload) {
610
+ const reservationId = readNumber(payload.reservationId, -1);
611
+ const connector = this.connectors.findByReservation(reservationId);
612
+ if (!connector) {
613
+ this.client.reply(messageId, { status: "Rejected" });
614
+ return;
615
+ }
616
+ this.clearReservation(connector.id);
617
+ this.client.reply(messageId, { status: "Accepted" });
618
+ void this.runSafely(() => this.statusNotification(connector.id, this.connectors.get(connector.id).status));
619
+ }
620
+ async handleChangeAvailability(messageId, payload) {
621
+ const availability = readString(payload.type, "Operative");
622
+ const connectorId = readNumber(payload.connectorId, 0);
623
+ const requestedConnector = connectorId === 0 ? undefined : this.connectors.find(connectorId);
624
+ if (connectorId !== 0 && !requestedConnector) {
625
+ this.client.reply(messageId, { status: "Rejected" });
626
+ return;
627
+ }
628
+ const connectors = connectorId === 0 ? this.connectors.all() : [requestedConnector ?? this.connectors.get(connectorId)];
629
+ const scheduled = connectors.some((connector) => connector.transactionId);
630
+ for (const connector of connectors) {
631
+ if (connector.transactionId) {
632
+ this.connectors.patch(connector.id, { availability });
633
+ continue;
634
+ }
635
+ this.connectors.patch(connector.id, { availability, status: statusAfterAvailabilityChange(connector, availability) });
636
+ }
637
+ this.client.reply(messageId, { status: scheduled ? "Scheduled" : "Accepted" });
638
+ for (const connector of connectors.filter((connector) => !connector.transactionId)) {
639
+ void this.runSafely(() => this.statusNotification(connector.id, this.connectors.get(connector.id).status));
640
+ }
641
+ }
642
+ handleChangeConfiguration(messageId, payload) {
643
+ const result = changeConfiguration(this.configuration, payload);
644
+ this.client.reply(messageId, { status: result.status });
645
+ if (result.status !== "Accepted") {
646
+ return;
647
+ }
648
+ this.savePersistedState();
649
+ if (result.key === "HeartbeatInterval") {
650
+ const interval = Number.parseInt(result.value, 10);
651
+ if (Number.isFinite(interval) && interval > 0) {
652
+ this.startHeartbeatTimer(interval);
653
+ }
654
+ }
655
+ if (result.key === "MeterValueSampleInterval") {
656
+ this.meterValueScheduler.reschedulePeriodic(this.connectors.all().map((connector) => connector.id));
657
+ }
658
+ if (result.key === "ClockAlignedDataInterval") {
659
+ this.meterValueScheduler.startClockAligned();
660
+ }
661
+ if (result.key === "MinimumStatusDuration" && result.value === "0") {
662
+ this.statusScheduler.flush();
663
+ }
664
+ }
665
+ handleDataTransfer(messageId, payload) {
666
+ const vendorId = readString(payload.vendorId, "");
667
+ if (vendorId !== this.config.vendor) {
668
+ this.client.reply(messageId, { status: "UnknownVendorId" });
669
+ return;
670
+ }
671
+ this.client.reply(messageId, { status: "Accepted", data: readString(payload.data, "") });
672
+ }
673
+ handleGetConfiguration(messageId, payload) {
674
+ this.client.reply(messageId, getConfiguration(this.configuration, payload));
675
+ }
676
+ handleSetChargingProfile(messageId, payload) {
677
+ const connectorId = readNumber(payload.connectorId, -1);
678
+ const status = this.chargingProfiles.set(connectorId, payload.csChargingProfiles, {
679
+ hasConnector: (id) => Boolean(this.connectors.find(id)),
680
+ transactionIdForConnector: (id) => this.connectors.find(id)?.transactionId
681
+ });
682
+ if (status !== "Accepted") {
683
+ this.client.reply(messageId, { status });
684
+ return;
685
+ }
686
+ this.savePersistedState();
687
+ this.client.reply(messageId, { status: "Accepted" });
688
+ }
689
+ handleClearChargingProfile(messageId, payload) {
690
+ const status = this.chargingProfiles.clear(payload);
691
+ if (status === "Accepted") {
692
+ this.savePersistedState();
693
+ }
694
+ this.client.reply(messageId, { status });
695
+ }
696
+ handleGetCompositeSchedule(messageId, payload) {
697
+ const connectorId = readNumber(payload.connectorId, -1);
698
+ const connector = connectorId === 0 ? undefined : this.connectors.find(connectorId);
699
+ if (connectorId !== 0 && !connector) {
700
+ this.client.reply(messageId, { status: "Rejected" });
701
+ return;
702
+ }
703
+ const schedule = this.chargingProfiles.compositeSchedulePayload(payload, connector?.transactionId, connector?.transactionStartedAt);
704
+ if (!schedule) {
705
+ this.client.reply(messageId, { status: "Rejected" });
706
+ return;
707
+ }
708
+ if (schedule.prunedExpired) {
709
+ this.savePersistedState();
710
+ }
711
+ this.client.reply(messageId, schedule.payload);
712
+ }
713
+ handleSendLocalList(messageId, payload) {
714
+ const result = applyLocalAuthListUpdate(this.localAuthorizationList, this.state.localListVersion, payload, {
715
+ enabled: this.configuration.boolean("LocalAuthListEnabled", true),
716
+ maxLength: this.configuration.integer("SendLocalListMaxLength", 1000)
717
+ });
718
+ if (result.status !== "Accepted") {
719
+ this.client.reply(messageId, { status: result.status });
720
+ return;
721
+ }
722
+ this.patchState({ localListVersion: result.listVersion });
723
+ this.savePersistedState();
724
+ this.client.reply(messageId, { status: "Accepted" });
725
+ }
726
+ async handleRemoteStartTransaction(messageId, payload) {
727
+ const requestedConnector = readNumber(payload.connectorId, 0);
728
+ const connector = requestedConnector > 0 ? this.connectors.find(requestedConnector) : this.connectors.findAvailable();
729
+ const idTag = readString(payload.idTag, "");
730
+ if (!idTag || !canRemoteStart(connector) || !this.canAcceptRemoteStartChargingProfile(payload)) {
731
+ this.client.reply(messageId, { status: "Rejected" });
732
+ return;
733
+ }
734
+ if (this.configuration.boolean("AuthorizeRemoteTxRequests", false)) {
735
+ const decision = await this.authorize(idTag);
736
+ if (!decision.accepted) {
737
+ this.client.reply(messageId, { status: "Rejected" });
738
+ return;
739
+ }
740
+ }
741
+ this.client.reply(messageId, { status: "Accepted" });
742
+ if (connector) {
743
+ const transactionId = await this.startTransaction(connector.id, idTag);
744
+ if (transactionId !== undefined) {
745
+ this.applyRemoteStartChargingProfile(connector.id, payload.chargingProfile, transactionId);
746
+ }
747
+ }
748
+ }
749
+ async handleRemoteStopTransaction(messageId, payload) {
750
+ const transactionId = readNumber(payload.transactionId, -1);
751
+ const connector = this.connectors.findByTransaction(transactionId);
752
+ const accepted = Boolean(connector);
753
+ this.client.reply(messageId, { status: accepted ? "Accepted" : "Rejected" });
754
+ if (accepted && connector) {
755
+ await this.stopTransaction(connector.id, "Remote");
756
+ }
757
+ }
758
+ async handleReserveNow(messageId, payload) {
759
+ const requestedConnectorId = readNumber(payload.connectorId, this.config.connectorId);
760
+ const connector = requestedConnectorId === 0 ? this.connectors.findAvailable() : this.connectors.find(requestedConnectorId);
761
+ const connectorId = connector?.id ?? requestedConnectorId;
762
+ const expiryDate = readString(payload.expiryDate, "");
763
+ const rejection = reserveNowRejection(connector, expiryDate);
764
+ if (rejection) {
765
+ this.client.reply(messageId, { status: rejection });
766
+ return;
767
+ }
768
+ this.connectors.patch(connectorId, {
769
+ reservationId: readNumber(payload.reservationId, Date.now()),
770
+ reservationIdTag: readString(payload.idTag, ""),
771
+ reservationParentIdTag: readOptionalString(payload.parentIdTag),
772
+ reservationExpiryDate: expiryDate,
773
+ status: "Reserved"
774
+ });
775
+ this.reservationScheduler.schedule(connectorId);
776
+ this.savePersistedState();
777
+ this.client.reply(messageId, { status: "Accepted" });
778
+ void this.runSafely(() => this.statusNotification(connectorId, "Reserved"));
779
+ }
780
+ handleReset(messageId, payload) {
781
+ const resetType = readString(payload.type, "Soft");
782
+ const reason = resetType === "Hard" ? "HardReset" : "SoftReset";
783
+ this.client.reply(messageId, { status: "Accepted" });
784
+ void this.runSafely(() => this.performReset(reason, resetType));
785
+ }
786
+ async handleTriggerMessage(messageId, payload) {
787
+ const requestedMessage = readString(payload.requestedMessage, "");
788
+ if (!canTriggerMessage(requestedMessage)) {
789
+ this.client.reply(messageId, { status: "NotImplemented" });
790
+ return;
791
+ }
792
+ if (!this.canTriggerMessageForPayload(requestedMessage, payload)) {
793
+ this.client.reply(messageId, { status: "Rejected" });
794
+ return;
795
+ }
796
+ this.client.reply(messageId, { status: "Accepted" });
797
+ await this.triggerMessage(requestedMessage, payload);
798
+ }
799
+ async handleUnlockConnector(messageId, payload = {}) {
800
+ const connectorId = readNumber(payload.connectorId, this.config.connectorId);
801
+ const connector = this.connectors.find(connectorId);
802
+ if (!connector) {
803
+ this.client.reply(messageId, { status: "NotSupported" });
804
+ return;
805
+ }
806
+ if (connector.transactionId) {
807
+ this.client.reply(messageId, { status: "Unlocked" });
808
+ await this.stopTransaction(connectorId, "UnlockCommand");
809
+ return;
810
+ }
811
+ this.client.reply(messageId, { status: "Unlocked" });
812
+ }
813
+ async triggerMessage(requestedMessage, payload = {}) {
814
+ switch (requestedMessage) {
815
+ case "BootNotification":
816
+ await this.boot();
817
+ break;
818
+ case "DiagnosticsStatusNotification":
819
+ await this.diagnosticsStatusNotification("Idle");
820
+ break;
821
+ case "FirmwareStatusNotification":
822
+ await this.firmwareStatusNotification("Idle");
823
+ break;
824
+ case "Heartbeat":
825
+ await this.heartbeat();
826
+ break;
827
+ case "MeterValues":
828
+ await this.meterValues(this.triggerConnectorId(payload), 0);
829
+ break;
830
+ case "StatusNotification":
831
+ await this.statusNotification(this.triggerConnectorId(payload), this.connectors.get(this.triggerConnectorId(payload)).status, true);
832
+ break;
833
+ default:
834
+ this.log("warn", `TriggerMessage requested unsupported message: ${requestedMessage}`);
835
+ }
836
+ }
837
+ canTriggerMessageForPayload(requestedMessage, payload) {
838
+ if (requestedMessage !== "MeterValues" && requestedMessage !== "StatusNotification") {
839
+ return true;
840
+ }
841
+ return Boolean(this.connectors.find(this.triggerConnectorId(payload)));
842
+ }
843
+ triggerConnectorId(payload) {
844
+ return "connectorId" in payload ? readNumber(payload.connectorId, -1) : this.config.connectorId;
845
+ }
846
+ canAcceptRemoteStartChargingProfile(payload) {
847
+ if (!("chargingProfile" in payload)) {
848
+ return true;
849
+ }
850
+ const profile = readChargingProfile(payload.chargingProfile);
851
+ return Boolean(profile &&
852
+ profile.chargingProfilePurpose === "TxProfile" &&
853
+ profile.transactionId === undefined);
854
+ }
855
+ applyRemoteStartChargingProfile(connectorId, profilePayload, transactionId) {
856
+ const profile = readChargingProfile(profilePayload);
857
+ if (!profile) {
858
+ return;
859
+ }
860
+ const status = this.chargingProfiles.set(connectorId, { ...profile, transactionId }, {
861
+ hasConnector: (id) => Boolean(this.connectors.find(id)),
862
+ transactionIdForConnector: (id) => this.connectors.find(id)?.transactionId
863
+ });
864
+ if (status === "Accepted") {
865
+ this.savePersistedState();
866
+ }
867
+ }
868
+ async send(action, payload) {
869
+ if (isTransactionMessage(action)) {
870
+ return this.sendTransactionMessage(action, payload);
871
+ }
872
+ try {
873
+ const response = await this.client.call(action, payload, this.config.callTimeoutMs ?? 30_000);
874
+ this.log("success", `${action} accepted`);
875
+ return response;
876
+ }
877
+ catch (error) {
878
+ const message = error instanceof Error ? error.message : String(error);
879
+ this.log("error", message);
880
+ throw error;
881
+ }
882
+ }
883
+ async sendTransactionMessage(action, payload) {
884
+ const attempts = this.configuration.integer("TransactionMessageAttempts", 1);
885
+ const retryInterval = this.configuration.integer("TransactionMessageRetryInterval", 30);
886
+ let lastError;
887
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
888
+ try {
889
+ const response = await this.client.call(action, payload, this.config.callTimeoutMs ?? 30_000);
890
+ this.log("success", `${action} accepted`);
891
+ return response;
892
+ }
893
+ catch (error) {
894
+ lastError = error;
895
+ if (attempt >= attempts) {
896
+ break;
897
+ }
898
+ const message = error instanceof Error ? error.message : String(error);
899
+ this.log("warn", `${action} attempt ${attempt}/${attempts} failed: ${message}; retrying in ${retryInterval}s`);
900
+ await retryDelay(retryInterval);
901
+ }
902
+ }
903
+ const message = lastError instanceof Error ? lastError.message : String(lastError);
904
+ this.log("error", message);
905
+ throw lastError instanceof Error ? lastError : new Error(message);
906
+ }
907
+ async sendMeterValues(connectorId, context, deltaWh, measurands) {
908
+ const connector = this.connectors.get(connectorId);
909
+ const nextMeter = connector.meterWh + deltaWh;
910
+ this.connectors.patch(connectorId, { meterWh: nextMeter });
911
+ this.savePersistedState();
912
+ if (!this.client.isConnected || connector.pendingStartTransaction) {
913
+ await this.stopTransactionIfInvalidEnergyLimitReached(connectorId, nextMeter);
914
+ return;
915
+ }
916
+ const payload = {
917
+ connectorId,
918
+ meterValue: [buildMeterValue(this.connectors.get(connectorId), context, measurands)]
919
+ };
920
+ if (connector.transactionId) {
921
+ payload.transactionId = connector.transactionId;
922
+ }
923
+ await this.send("MeterValues", payload);
924
+ await this.stopTransactionIfInvalidEnergyLimitReached(connectorId, nextMeter);
925
+ }
926
+ buildStopTransactionData(connectorId) {
927
+ return [
928
+ buildMeterValue(this.connectors.get(connectorId), "Transaction.End", this.configuration.measurands("StopTxnSampledData")),
929
+ buildMeterValue(this.connectors.get(connectorId), "Sample.Clock", this.configuration.measurands("StopTxnAlignedData"))
930
+ ];
931
+ }
932
+ authorizeLocally(idTag, requireOfflinePermission) {
933
+ return authorizeLocally(idTag, requireOfflinePermission, this.localAuthorizationList, this.authorizationCache, {
934
+ localAuthListEnabled: this.configuration.boolean("LocalAuthListEnabled", true),
935
+ localAuthorizeOffline: this.configuration.boolean("LocalAuthorizeOffline", false),
936
+ authorizationCacheEnabled: this.configuration.boolean("AuthorizationCacheEnabled", true),
937
+ allowOfflineTxForUnknownId: this.configuration.boolean("AllowOfflineTxForUnknownId", false)
938
+ });
939
+ }
940
+ rememberAuthorization(idTag, idTagInfo) {
941
+ if (!this.configuration.boolean("AuthorizationCacheEnabled", true)) {
942
+ return;
943
+ }
944
+ this.authorizationCache.set(idTag, serializeIdTagInfo(idTagInfo));
945
+ this.savePersistedState();
946
+ }
947
+ readIdTagInfo(value) {
948
+ return normalizeIdTagInfo(readObject(value));
949
+ }
950
+ async stopTransactionsForInvalidIdTag(idTag) {
951
+ if (!this.configuration.boolean("StopTransactionOnInvalidId", false)) {
952
+ return;
953
+ }
954
+ const maxEnergyOnInvalidId = this.configuration.integer("MaxEnergyOnInvalidId", 0);
955
+ const connectors = this.connectors.all().filter((connector) => connector.transactionId && connector.lastIdTag === idTag);
956
+ for (const connector of connectors) {
957
+ if (maxEnergyOnInvalidId <= 0) {
958
+ await this.stopTransaction(connector.id, "DeAuthorized", idTag);
959
+ continue;
960
+ }
961
+ this.connectors.patch(connector.id, { stopTransactionAtWh: connector.meterWh + maxEnergyOnInvalidId });
962
+ this.log("warn", `Connector ${connector.id} will stop after ${maxEnergyOnInvalidId}Wh because idTag ${idTag} is no longer authorized`);
963
+ }
964
+ }
965
+ async stopTransactionIfInvalidEnergyLimitReached(connectorId, meterWh) {
966
+ const connector = this.connectors.get(connectorId);
967
+ if (!connector.transactionId || connector.stopTransactionAtWh === undefined || meterWh < connector.stopTransactionAtWh) {
968
+ return;
969
+ }
970
+ await this.stopTransaction(connectorId, "DeAuthorized", connector.lastIdTag);
971
+ }
972
+ refreshConnectorSnapshot() {
973
+ const { connectors, primary } = this.connectors.snapshot();
974
+ this.state.connectors = connectors;
975
+ if (primary) {
976
+ this.state.connectorStatus = primary.status;
977
+ this.state.availability = primary.availability;
978
+ this.state.transactionId = primary.transactionId;
979
+ this.state.reservationId = primary.reservationId;
980
+ this.state.meterWh = primary.meterWh;
981
+ }
982
+ this.emit("state", { ...this.state, connectors });
983
+ }
984
+ clearReservation(connectorId) {
985
+ const connector = this.connectors.get(connectorId);
986
+ this.reservationScheduler.clear(connectorId);
987
+ this.connectors.patch(connectorId, {
988
+ reservationId: undefined,
989
+ reservationIdTag: undefined,
990
+ reservationParentIdTag: undefined,
991
+ reservationExpiryDate: undefined,
992
+ status: statusAfterReservationClear(connector)
993
+ });
994
+ this.savePersistedState();
995
+ }
996
+ async sendClockAlignedMeterValues(connectorIds, deltaWh) {
997
+ const measurands = this.configuration.measurands("MeterValuesAlignedData");
998
+ for (const connectorId of connectorIds) {
999
+ await this.sendMeterValues(connectorId, "Sample.Clock", deltaWh, measurands);
1000
+ }
1001
+ }
1002
+ expireReservation(connectorId) {
1003
+ const connector = this.connectors.find(connectorId);
1004
+ if (!connector?.reservationId) {
1005
+ return;
1006
+ }
1007
+ this.log("warn", `Reservation ${connector.reservationId} expired on connector ${connectorId}`);
1008
+ this.clearReservation(connectorId);
1009
+ void this.runSafely(() => this.statusNotification(connectorId, this.connectors.get(connectorId).status));
1010
+ }
1011
+ loadPersistedState() {
1012
+ if (!this.store) {
1013
+ return;
1014
+ }
1015
+ let persisted;
1016
+ try {
1017
+ persisted = this.store.load();
1018
+ }
1019
+ catch (error) {
1020
+ const message = error instanceof Error ? error.message : String(error);
1021
+ this.log("warn", `Could not load persisted station state: ${message}`);
1022
+ return;
1023
+ }
1024
+ if (!persisted) {
1025
+ return;
1026
+ }
1027
+ const restored = restorePersistedStationState({
1028
+ persisted,
1029
+ connectorId: this.config.connectorId,
1030
+ connectors: this.connectors,
1031
+ configuration: this.configuration,
1032
+ localAuthorizationList: this.localAuthorizationList,
1033
+ authorizationCache: this.authorizationCache,
1034
+ scheduleReservation: (connectorId) => this.reservationScheduler.schedule(connectorId)
1035
+ });
1036
+ this.state.localListVersion = restored.localListVersion;
1037
+ this.chargingProfiles.replace(restored.chargingProfiles);
1038
+ }
1039
+ savePersistedState() {
1040
+ if (!this.store) {
1041
+ return;
1042
+ }
1043
+ try {
1044
+ this.store.save(buildPersistedStationState({
1045
+ chargePointId: this.config.chargePointId,
1046
+ connectorId: this.config.connectorId,
1047
+ connectors: this.connectors.all(),
1048
+ localListVersion: this.state.localListVersion,
1049
+ localAuthorizationList: this.localAuthorizationList,
1050
+ authorizationCache: this.authorizationCache,
1051
+ chargingProfiles: this.chargingProfiles.entries(),
1052
+ configuration: this.configuration.values
1053
+ }));
1054
+ }
1055
+ catch (error) {
1056
+ const message = error instanceof Error ? error.message : String(error);
1057
+ this.log("error", `Could not save station state: ${message}`);
1058
+ }
1059
+ }
1060
+ startHeartbeatTimer(intervalSeconds) {
1061
+ this.stopHeartbeatTimer();
1062
+ this.heartbeatTimer = setInterval(() => {
1063
+ void this.runSafely(() => this.heartbeat());
1064
+ }, intervalSeconds * 1000);
1065
+ }
1066
+ stopHeartbeatTimer() {
1067
+ if (!this.heartbeatTimer) {
1068
+ return;
1069
+ }
1070
+ clearInterval(this.heartbeatTimer);
1071
+ this.heartbeatTimer = undefined;
1072
+ }
1073
+ async performReset(reason, resetType) {
1074
+ this.log("warn", `${resetType} reset accepted; rebooting station`);
1075
+ this.stopHeartbeatTimer();
1076
+ this.clearResetTimer();
1077
+ this.maintenance.clearAll();
1078
+ this.meterValueScheduler.stopClockAligned();
1079
+ for (const connector of this.connectors.all()) {
1080
+ if (connector.transactionId) {
1081
+ await this.stopTransaction(connector.id, reason);
1082
+ const resetStatus = this.connectors.get(connector.id).availability === "Inoperative" ? "Unavailable" : "Available";
1083
+ this.connectors.patch(connector.id, { evConnected: false, status: resetStatus });
1084
+ void this.runSafely(() => this.statusNotification(connector.id, resetStatus));
1085
+ }
1086
+ }
1087
+ this.meterValueScheduler.stopAllPeriodic();
1088
+ for (const connector of this.connectors.all()) {
1089
+ if (!connector.reservationId) {
1090
+ continue;
1091
+ }
1092
+ this.clearReservation(connector.id);
1093
+ void this.runSafely(() => this.statusNotification(connector.id, this.connectors.get(connector.id).status));
1094
+ }
1095
+ this.patchState({ booted: false, registrationStatus: "Unknown" });
1096
+ this.resetTimer = setTimeout(() => {
1097
+ this.resetTimer = undefined;
1098
+ if (this.state.connected) {
1099
+ void this.runSafely(() => this.bootAfterReset(this.configuration.integer("ResetRetries", 0)));
1100
+ }
1101
+ }, 500);
1102
+ }
1103
+ async bootAfterReset(remainingRetries) {
1104
+ await this.boot({ scheduleRetry: false });
1105
+ if (this.state.registrationStatus === "Accepted" || remainingRetries <= 0 || !this.state.connected) {
1106
+ return;
1107
+ }
1108
+ this.log("warn", `Reset reboot BootNotification was ${this.state.registrationStatus}; retrying (${remainingRetries} left)`);
1109
+ this.resetTimer = setTimeout(() => {
1110
+ this.resetTimer = undefined;
1111
+ if (this.state.connected) {
1112
+ void this.runSafely(() => this.bootAfterReset(remainingRetries - 1));
1113
+ }
1114
+ }, 500);
1115
+ }
1116
+ clearResetTimer() {
1117
+ if (!this.resetTimer) {
1118
+ return;
1119
+ }
1120
+ clearTimeout(this.resetTimer);
1121
+ this.resetTimer = undefined;
1122
+ }
1123
+ scheduleBootRetry(intervalSeconds) {
1124
+ if (this.bootRetryTimer || !this.state.connected) {
1125
+ return;
1126
+ }
1127
+ const delayMs = Math.max(1, intervalSeconds) * 1000;
1128
+ this.log("warn", `BootNotification was ${this.state.registrationStatus}; retrying in ${delayMs}ms`);
1129
+ this.bootRetryTimer = setTimeout(() => {
1130
+ this.bootRetryTimer = undefined;
1131
+ if (this.state.connected) {
1132
+ void this.runSafely(() => this.boot());
1133
+ }
1134
+ }, delayMs);
1135
+ }
1136
+ clearBootRetryTimer() {
1137
+ if (!this.bootRetryTimer) {
1138
+ return;
1139
+ }
1140
+ clearTimeout(this.bootRetryTimer);
1141
+ this.bootRetryTimer = undefined;
1142
+ }
1143
+ scheduleReconnect() {
1144
+ if (this.manuallyDisconnected ||
1145
+ !this.config.webSocketReconnectEnabled ||
1146
+ this.reconnectTimer ||
1147
+ this.state.connected) {
1148
+ return;
1149
+ }
1150
+ const maxAttempts = this.config.webSocketReconnectMaxAttempts;
1151
+ if (maxAttempts > 0 && this.reconnectAttempts >= maxAttempts) {
1152
+ this.log("error", `Reconnect stopped after ${this.reconnectAttempts} failed attempt(s)`);
1153
+ return;
1154
+ }
1155
+ this.reconnectAttempts += 1;
1156
+ const delayMs = this.reconnectDelayMs(this.reconnectAttempts);
1157
+ this.log("warn", `Reconnect attempt ${this.reconnectAttempts}${maxAttempts > 0 ? `/${maxAttempts}` : ""} in ${delayMs}ms`);
1158
+ this.reconnectTimer = setTimeout(() => {
1159
+ this.reconnectTimer = undefined;
1160
+ if (this.manuallyDisconnected || this.state.connected) {
1161
+ return;
1162
+ }
1163
+ this.log("info", `Reconnecting to ${this.config.centralSystemUrl}`);
1164
+ this.client.connect();
1165
+ }, delayMs);
1166
+ }
1167
+ reconnectDelayMs(attempt) {
1168
+ const initial = Math.max(0, this.config.webSocketReconnectInitialDelayMs);
1169
+ const max = Math.max(initial, this.config.webSocketReconnectMaxDelayMs);
1170
+ return Math.min(initial * 2 ** Math.max(0, attempt - 1), max);
1171
+ }
1172
+ clearReconnectTimer() {
1173
+ if (!this.reconnectTimer) {
1174
+ return;
1175
+ }
1176
+ clearTimeout(this.reconnectTimer);
1177
+ this.reconnectTimer = undefined;
1178
+ }
1179
+ patchState(patch) {
1180
+ Object.assign(this.state, patch);
1181
+ this.emit("state", { ...this.state });
1182
+ }
1183
+ log(level, message, details) {
1184
+ this.emit("log", { at: new Date(), level, message, details });
1185
+ }
1186
+ async runSafely(task) {
1187
+ try {
1188
+ await task();
1189
+ }
1190
+ catch {
1191
+ // Errors are logged in send(); keeping async UI handlers quiet avoids unhandled rejections.
1192
+ }
1193
+ }
1194
+ }
1195
+ function trimOptional(value, maxLength) {
1196
+ const trimmed = value?.trim();
1197
+ if (!trimmed) {
1198
+ return undefined;
1199
+ }
1200
+ return trimmed.slice(0, maxLength);
1201
+ }