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
package/dist/tui.js ADDED
@@ -0,0 +1,1601 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import blessed from "blessed";
3
+ const TABS = [
4
+ { id: "station", label: "Station" },
5
+ { id: "connector", label: "Connector" },
6
+ { id: "transaction", label: "Transaction" },
7
+ { id: "maintenance", label: "Maintenance" },
8
+ { id: "data", label: "Data" },
9
+ { id: "logs", label: "Logs" },
10
+ { id: "scenarios", label: "Scenarios" }
11
+ ];
12
+ const LEVEL_STYLE = {
13
+ info: "{blue-fg}info{/blue-fg}",
14
+ success: "{green-fg}ok{/green-fg}",
15
+ warn: "{yellow-fg}warn{/yellow-fg}",
16
+ error: "{red-fg}err{/red-fg}",
17
+ in: "{magenta-fg}in{/magenta-fg}",
18
+ out: "{cyan-fg}out{/cyan-fg}"
19
+ };
20
+ const DEFAULT_MEASURANDS = "Energy.Active.Import.Register,Power.Active.Import,Current.Import,Voltage,Temperature,SoC";
21
+ const THEME = {
22
+ panelBorder: "cyan",
23
+ headerBg: "black",
24
+ headerFg: "white",
25
+ footerBg: "black",
26
+ footerFg: "white",
27
+ selectedBg: "white",
28
+ selectedFg: "black"
29
+ };
30
+ export class Tui {
31
+ station;
32
+ screen;
33
+ header;
34
+ connectorList;
35
+ detailBox;
36
+ actionList;
37
+ logBox;
38
+ footer;
39
+ logs = [];
40
+ activeTab = "station";
41
+ logFilter = "all";
42
+ actions = [];
43
+ modal;
44
+ plainLogView;
45
+ selectedConnectorId;
46
+ constructor(station) {
47
+ this.station = station;
48
+ this.selectedConnectorId = station.config.connectorId;
49
+ this.screen = blessed.screen({
50
+ smartCSR: true,
51
+ mouse: false,
52
+ title: "kirby-ocpp"
53
+ });
54
+ this.header = blessed.box({
55
+ top: 0,
56
+ left: 0,
57
+ width: "100%",
58
+ height: 3,
59
+ tags: true,
60
+ style: { fg: THEME.headerFg, bg: THEME.headerBg }
61
+ });
62
+ this.connectorList = blessed.list({
63
+ label: " Connectors ",
64
+ top: 3,
65
+ left: 0,
66
+ width: "25%",
67
+ bottom: 3,
68
+ keys: true,
69
+ vi: false,
70
+ tags: true,
71
+ border: "line",
72
+ padding: { left: 1, right: 1 },
73
+ style: {
74
+ selected: { bg: THEME.selectedBg, fg: THEME.selectedFg },
75
+ border: { fg: THEME.panelBorder }
76
+ }
77
+ });
78
+ this.detailBox = blessed.box({
79
+ label: " Details ",
80
+ top: 3,
81
+ left: "25%",
82
+ width: "37%",
83
+ height: "48%",
84
+ tags: true,
85
+ border: "line",
86
+ padding: { left: 1, right: 1 },
87
+ style: {
88
+ border: { fg: THEME.panelBorder }
89
+ }
90
+ });
91
+ this.actionList = blessed.list({
92
+ label: " Actions ",
93
+ top: "51%",
94
+ left: "25%",
95
+ width: "37%",
96
+ bottom: 3,
97
+ keys: true,
98
+ vi: false,
99
+ tags: true,
100
+ border: "line",
101
+ padding: { left: 1, right: 1 },
102
+ style: {
103
+ selected: { bg: THEME.selectedBg, fg: THEME.selectedFg },
104
+ border: { fg: THEME.panelBorder }
105
+ }
106
+ });
107
+ this.logBox = blessed.log({
108
+ label: " OCPP log [all] ",
109
+ top: 3,
110
+ left: "62%",
111
+ width: "38%",
112
+ bottom: 3,
113
+ tags: true,
114
+ keys: true,
115
+ vi: true,
116
+ mouse: false,
117
+ scrollable: true,
118
+ alwaysScroll: true,
119
+ border: "line",
120
+ padding: { left: 1, right: 1 },
121
+ scrollbar: {
122
+ ch: " ",
123
+ style: { bg: THEME.panelBorder }
124
+ },
125
+ style: {
126
+ border: { fg: THEME.panelBorder }
127
+ }
128
+ });
129
+ this.footer = blessed.box({
130
+ bottom: 0,
131
+ left: 0,
132
+ width: "100%",
133
+ height: 3,
134
+ tags: true,
135
+ style: { fg: THEME.footerFg, bg: THEME.footerBg }
136
+ });
137
+ this.screen.append(this.header);
138
+ this.screen.append(this.connectorList);
139
+ this.screen.append(this.detailBox);
140
+ this.screen.append(this.actionList);
141
+ this.screen.append(this.logBox);
142
+ this.screen.append(this.footer);
143
+ this.layout();
144
+ this.bindKeys();
145
+ this.bindStation();
146
+ this.render();
147
+ this.actionList.focus();
148
+ }
149
+ start() {
150
+ this.screen.render();
151
+ this.station.connect();
152
+ }
153
+ stop() {
154
+ this.station.disconnect();
155
+ this.screen.destroy();
156
+ }
157
+ bindKeys() {
158
+ this.screen.key("escape", () => {
159
+ if (this.closeModal()) {
160
+ return;
161
+ }
162
+ this.closePlainLogView();
163
+ });
164
+ this.screen.key("q", () => {
165
+ if (this.modal) {
166
+ return;
167
+ }
168
+ if (this.closePlainLogView()) {
169
+ return;
170
+ }
171
+ this.stopAndExit();
172
+ });
173
+ this.screen.key("C-c", () => {
174
+ if (this.closeModal() || this.closePlainLogView()) {
175
+ return;
176
+ }
177
+ this.stopAndExit();
178
+ });
179
+ this.screen.key(["tab"], () => this.runWithoutOverlay(() => this.nextTab()));
180
+ this.screen.key(["S-tab"], () => this.runWithoutOverlay(() => this.previousTab()));
181
+ this.screen.key(["1"], () => this.runWithoutOverlay(() => this.setTab("station")));
182
+ this.screen.key(["2"], () => this.runWithoutOverlay(() => this.setTab("connector")));
183
+ this.screen.key(["3"], () => this.runWithoutOverlay(() => this.setTab("transaction")));
184
+ this.screen.key(["4"], () => this.runWithoutOverlay(() => this.setTab("maintenance")));
185
+ this.screen.key(["5"], () => this.runWithoutOverlay(() => this.setTab("data")));
186
+ this.screen.key(["6"], () => this.runWithoutOverlay(() => this.setTab("logs")));
187
+ this.screen.key(["7"], () => this.runWithoutOverlay(() => this.setTab("scenarios")));
188
+ this.screen.key(["enter"], () => this.runWithoutOverlay(() => this.runSelectedAction()));
189
+ this.screen.key(["[", "left"], () => this.runWithoutOverlay(() => this.selectPreviousConnector()));
190
+ this.screen.key(["]", "right"], () => this.runWithoutOverlay(() => this.selectNextConnector()));
191
+ this.screen.key("+", () => this.runWithoutOverlay(() => this.addConnector()));
192
+ this.screen.key(["j"], () => this.runWithoutModal(() => this.scrollLog(1)));
193
+ this.screen.key(["k"], () => this.runWithoutModal(() => this.scrollLog(-1)));
194
+ this.screen.key(["g", "G", "S-g", "end"], () => this.runWithoutModal(() => this.scrollLogToEnd()));
195
+ this.screen.key("?", () => this.runWithoutOverlay(() => this.openHelp()));
196
+ this.screen.key("l", () => this.runWithoutOverlay(() => this.openPlainLogView()));
197
+ this.screen.key("y", () => this.runWithoutModal(() => this.copyLogsToClipboard()));
198
+ this.screen.key(["F", "S-f"], () => this.runWithoutOverlay(() => this.cycleLogFilter()));
199
+ this.screen.key("r", () => this.runWithoutOverlay(() => this.station.reconnect()));
200
+ this.bindActionAccelerators();
201
+ this.screen.key("/", () => this.runWithoutOverlay(() => this.openCommandPalette()));
202
+ this.actionList.on("select item", () => {
203
+ this.renderFooter();
204
+ this.screen.render();
205
+ });
206
+ this.screen.on("resize", () => {
207
+ this.layout();
208
+ this.render();
209
+ });
210
+ this.bindLogNavigationKeys(this.logBox);
211
+ }
212
+ bindStation() {
213
+ this.station.on("state", () => this.render());
214
+ this.station.on("log", (entry) => this.renderLog(entry));
215
+ }
216
+ layout() {
217
+ const columns = this.screenColumns();
218
+ const rows = this.screenRows();
219
+ const compact = columns < 110;
220
+ if (compact) {
221
+ const upperHeight = Math.max(7, Math.floor((rows - 6) * 0.42));
222
+ const actionHeight = Math.max(5, Math.floor((rows - 6) * 0.28));
223
+ const actionTop = 3 + upperHeight;
224
+ const logTop = actionTop + actionHeight;
225
+ this.connectorList.top = 3;
226
+ this.connectorList.left = 0;
227
+ this.connectorList.width = "34%";
228
+ this.connectorList.height = upperHeight;
229
+ deleteRuntimeProperty(this.connectorList, "bottom");
230
+ this.detailBox.top = 3;
231
+ this.detailBox.left = "34%";
232
+ this.detailBox.width = "66%";
233
+ this.detailBox.height = upperHeight;
234
+ deleteRuntimeProperty(this.detailBox, "bottom");
235
+ this.actionList.top = actionTop;
236
+ this.actionList.left = 0;
237
+ this.actionList.width = "100%";
238
+ this.actionList.height = actionHeight;
239
+ deleteRuntimeProperty(this.actionList, "bottom");
240
+ this.logBox.left = 0;
241
+ this.logBox.width = "100%";
242
+ this.logBox.top = logTop;
243
+ this.logBox.bottom = 3;
244
+ deleteRuntimeProperty(this.logBox, "height");
245
+ return;
246
+ }
247
+ this.connectorList.top = 3;
248
+ this.connectorList.left = 0;
249
+ this.connectorList.width = "25%";
250
+ deleteRuntimeProperty(this.connectorList, "height");
251
+ this.connectorList.bottom = 3;
252
+ this.detailBox.top = 3;
253
+ this.detailBox.left = "25%";
254
+ this.detailBox.width = "37%";
255
+ this.detailBox.height = "48%";
256
+ deleteRuntimeProperty(this.detailBox, "bottom");
257
+ this.actionList.left = "25%";
258
+ this.actionList.width = "37%";
259
+ this.actionList.top = "51%";
260
+ deleteRuntimeProperty(this.actionList, "height");
261
+ this.actionList.bottom = 3;
262
+ this.logBox.left = "62%";
263
+ this.logBox.width = "38%";
264
+ this.logBox.top = 3;
265
+ deleteRuntimeProperty(this.logBox, "height");
266
+ this.logBox.bottom = 3;
267
+ }
268
+ render() {
269
+ this.ensureSelectedConnector();
270
+ this.renderHeader();
271
+ this.renderConnectors();
272
+ this.renderDetails();
273
+ this.renderActions();
274
+ this.renderFooter();
275
+ this.screen.render();
276
+ }
277
+ renderHeader() {
278
+ const state = this.station.state;
279
+ const connection = state.connected ? "{green-fg}connected{/green-fg}" : "{red-fg}offline{/red-fg}";
280
+ const boot = state.booted ? "{green-fg}accepted{/green-fg}" : `{yellow-fg}${state.registrationStatus}{/yellow-fg}`;
281
+ const compact = this.screenColumns() < 110;
282
+ const tabLine = TABS.map((tab, index) => {
283
+ const label = compact ? `${index + 1}:${tab.label.slice(0, 4)}` : `${index + 1}:${tab.label}`;
284
+ return tab.id === this.activeTab ? `{black-fg}{white-bg}${label}{/white-bg}{/black-fg}` : `{gray-fg}${label}{/gray-fg}`;
285
+ }).join(" ");
286
+ this.header.setContent([
287
+ ` {bold}kirby-ocpp{/bold} ${this.station.config.chargePointId} -> ${this.station.config.centralSystemUrl}`,
288
+ ` ${connection} boot=${boot} selected=#${this.selectedConnectorId} ${tabLine}`
289
+ ].join("\n"));
290
+ }
291
+ renderConnectors() {
292
+ const rows = this.connectorContentRows();
293
+ const contentWidth = Math.max(20, this.connectorContentWidth());
294
+ const items = this.station.state.connectors.map((connector) => truncateText(this.connectorLine(connector, contentWidth), contentWidth));
295
+ const selectedIndex = Math.max(0, this.station.state.connectors.findIndex((connector) => connector.id === this.selectedConnectorId));
296
+ const hasHeader = contentWidth >= 36 && rows > 1;
297
+ const visibleRows = Math.max(hasHeader ? rows - 1 : rows, 1);
298
+ const start = clamp(selectedIndex - visibleRows + 1, 0, Math.max(0, items.length - visibleRows));
299
+ const visibleItems = items.slice(start, start + visibleRows);
300
+ this.connectorList.setLabel(" Connectors ");
301
+ this.connectorList.setItems(hasHeader ? [this.connectorHeaderLine(contentWidth), ...visibleItems] : visibleItems);
302
+ const maxSelectedIndex = Math.max(0, visibleItems.length - 1 + (hasHeader ? 1 : 0));
303
+ this.connectorList.select(clamp(selectedIndex - start + (hasHeader ? 1 : 0), 0, maxSelectedIndex));
304
+ }
305
+ connectorHeaderLine(width) {
306
+ return truncateText(["id ".padEnd(3), "status".padEnd(8), "ev", "tx".padEnd(6), "meter".padStart(7)].join(" | "), width);
307
+ }
308
+ connectorLine(connector, width) {
309
+ const status = connector.status === "Faulted" ? shortErrorCode(connector.errorCode) : shortConnectorStatus(connector.status);
310
+ const ev = connector.evConnected ? "EV" : "--";
311
+ const tx = connector.transactionId ? String(connector.transactionId) : "-";
312
+ const meter = `${Math.round(connector.meterWh)}Wh`;
313
+ const flags = [
314
+ connector.pendingStartTransaction || connector.pendingStopTransaction ? "sync" : "",
315
+ connector.reservationId ? `res:${connector.reservationId}` : ""
316
+ ].filter(Boolean);
317
+ if (width < 31) {
318
+ return [`#${connector.id}`, status, ev, meter, tx !== "-" ? `tx:${tx}` : "", ...flags].filter(Boolean).join(" ");
319
+ }
320
+ const base = [
321
+ `#${String(connector.id).padEnd(2)}`,
322
+ truncateText(status, 8).padEnd(8),
323
+ ev.padEnd(2),
324
+ truncateText(tx, 6).padEnd(6),
325
+ meter.padStart(7)
326
+ ].join(" | ");
327
+ return flags.length > 0 ? `${base} ${flags.join(" ")}` : base;
328
+ }
329
+ renderDetails() {
330
+ const state = this.station.state;
331
+ const connector = this.selectedConnector(state);
332
+ const lastHeartbeat = state.lastHeartbeatAt ? state.lastHeartbeatAt.toLocaleTimeString() : "-";
333
+ const contentWidth = this.centerContentWidth();
334
+ const lines = [
335
+ "{bold}Station{/bold}",
336
+ row("Connection", state.connected ? "{green-fg}{bold}connected{/bold}{/green-fg}" : "{red-fg}{bold}offline{/bold}{/red-fg}"),
337
+ row("Boot", state.booted ? "{green-fg}accepted{/green-fg}" : state.registrationStatus),
338
+ row("Local list", String(state.localListVersion)),
339
+ row("Heartbeat", lastHeartbeat),
340
+ row("Diag/FW", `${state.diagnosticsStatus ?? "-"}/${state.firmwareStatus ?? "-"}`),
341
+ "",
342
+ `{bold}Connector #${connector?.id ?? "-"}{/bold}`,
343
+ row("Status", connector?.status ?? "-"),
344
+ row("Availability", connector?.availability ?? "-"),
345
+ row("EV", connector?.evConnected ? "connected" : "-"),
346
+ row("Transaction", connector?.transactionId ? String(connector.transactionId) : "-"),
347
+ row("Pending sync", connector?.pendingStartTransaction || connector?.pendingStopTransaction ? "yes" : "-"),
348
+ row("Reservation", connector?.reservationId ? String(connector.reservationId) : "-"),
349
+ row("Reserve idTag", connector?.reservationIdTag ?? "-"),
350
+ row("Meter", `${Math.round(connector?.meterWh ?? 0)} Wh`),
351
+ row("Error", connector?.errorCode ?? "-")
352
+ ];
353
+ this.detailBox.setLabel(` ${this.tabLabel(this.activeTab)} / Details `);
354
+ this.detailBox.setContent(lines.map((line) => truncateTaggedLine(line, contentWidth)).join("\n"));
355
+ }
356
+ renderActions() {
357
+ this.actions = this.actionsForTab();
358
+ const width = this.centerContentWidth();
359
+ const items = this.actions.map((action) => {
360
+ const prefix = action.disabled?.() ? "{gray-fg}" : "";
361
+ const suffix = action.disabled?.() ? "{/gray-fg}" : "";
362
+ return `${prefix}${action.key.padEnd(2)} ${truncateText(action.label, Math.max(12, width - 6))}${suffix}`;
363
+ });
364
+ this.actionList.setLabel(` ${this.tabLabel(this.activeTab)} actions `);
365
+ this.actionList.setItems(items);
366
+ const selected = this.selectedActionIndex();
367
+ this.actionList.select(Math.min(selected, Math.max(0, items.length - 1)));
368
+ const action = this.actions[this.selectedActionIndex()];
369
+ const description = action ? action.description : "Choose an action and press Enter.";
370
+ this.footer.setContent(this.footerContent(description));
371
+ }
372
+ renderFooter() {
373
+ const action = this.actions[this.selectedActionIndex()];
374
+ this.footer.setContent(this.footerContent(action?.description ?? "Choose an action and press Enter."));
375
+ }
376
+ footerContent(description) {
377
+ return [
378
+ ` ${truncateText(description, Math.max(30, this.screenColumns() - 2))}`,
379
+ " Enter run / commands Up/Down action 1-7 tabs [/] connector + add j/k log F filter l plain y copy q quit"
380
+ ].join("\n");
381
+ }
382
+ actionsForTab(tab = this.activeTab) {
383
+ switch (tab) {
384
+ case "station":
385
+ return [
386
+ action("b", "BootNotification", "Register/re-register this charge point.", () => this.runStationTask(() => this.station.boot())),
387
+ action("h", "Heartbeat", "Send Heartbeat and update Central System time.", () => this.runStationTask(() => this.station.heartbeat())),
388
+ action("r", "Reconnect", "Close and reopen the WebSocket connection.", () => this.station.reconnect()),
389
+ action("x", "Disconnect", "Disconnect without exiting the emulator.", () => this.station.disconnect(), () => !this.station.state.connected),
390
+ action("c", "Connect", "Connect after a manual disconnect.", () => this.station.connect(), () => this.station.state.connected),
391
+ action("+", "Add connector", "Add a connector to this station.", () => this.addConnector())
392
+ ];
393
+ case "connector":
394
+ return [
395
+ action("p", "Plug / unplug EV", "Toggle EV plug state on the selected connector.", () => this.togglePlug()),
396
+ action("s", "Cycle status", "Cycle StatusNotification status on the selected connector.", () => this.runStationTask(() => this.station.cycleStatus(this.selectedConnectorId))),
397
+ action("n", "Send StatusNotification", "Send current selected connector status now.", () => this.runStationTask(() => this.station.statusNotification(this.selectedConnectorId, this.selectedConnector()?.status, true))),
398
+ action("f", "Fault / clear fault", "Inject or clear a fault on the selected connector.", () => this.faultFlow()),
399
+ action("]", "Next connector", "Select next connector.", () => this.selectNextConnector()),
400
+ action("[", "Previous connector", "Select previous connector.", () => this.selectPreviousConnector())
401
+ ];
402
+ case "transaction":
403
+ return [
404
+ action("a", "Authorize", "Prompt for idTag and send Authorize.", () => this.promptText("Authorize idTag", "", (idTag) => this.runStationTask(() => this.station.authorize(idTag)))),
405
+ action("t", "Start transaction", "Prompt for connector and idTag, then start a transaction.", () => this.startTransactionFlow()),
406
+ action("e", "Stop transaction", "Choose StopTransaction reason and stop selected connector.", () => this.stopTransactionFlow(), () => !this.selectedConnector()?.transactionId),
407
+ action("m", "MeterValues +120 Wh", "Send MeterValues for selected connector.", () => this.runStationTask(() => this.station.meterValues(this.selectedConnectorId))),
408
+ action("M", "MeterValues custom", "Edit connector, Wh delta, and measurands.", () => this.customMeterValuesFlow())
409
+ ];
410
+ case "maintenance":
411
+ return [
412
+ action("d", "Diagnostics outcome", "Set simulated diagnostics upload outcome.", () => this.promptChoice("Diagnostics outcome", ["success", "uploadFailure"], (value) => {
413
+ this.station.setDiagnosticsOutcome(readDiagnosticsOutcome(value));
414
+ this.render();
415
+ })),
416
+ action("u", "Firmware outcome", "Set simulated firmware download/install outcome.", () => this.promptChoice("Firmware outcome", ["success", "downloadFailure", "installationFailure"], (value) => {
417
+ this.station.setFirmwareOutcome(readFirmwareOutcome(value));
418
+ this.render();
419
+ })),
420
+ action("D", "DiagnosticsStatusNotification", "Send DiagnosticsStatusNotification with selected status.", () => this.promptChoice("Diagnostics status", ["Idle", "Uploading", "Uploaded", "UploadFailed"], (value) => this.runStationTask(() => this.station.diagnosticsStatusNotification(readDiagnosticsStatus(value))))),
421
+ action("U", "FirmwareStatusNotification", "Send FirmwareStatusNotification with selected status.", () => this.promptChoice("Firmware status", ["Idle", "Downloading", "Downloaded", "Installing", "Installed", "DownloadFailed", "InstallationFailed"], (value) => this.runStationTask(() => this.station.firmwareStatusNotification(readFirmwareStatus(value)))))
422
+ ];
423
+ case "data":
424
+ return [
425
+ action("d", "DataTransfer form", "Edit vendorId, messageId, and data.", () => this.dataTransferFlow()),
426
+ action("v", "Vendor ping", "Send a simple vendor DataTransfer ping.", () => this.runStationTask(() => this.station.dataTransfer({ vendorId: this.station.config.vendor, data: "ping" })))
427
+ ];
428
+ case "logs":
429
+ return [
430
+ action("l", "Open plain log", "Open full-screen plain log view for clean selection.", () => this.openPlainLogView()),
431
+ action("y", "Copy logs", "Copy current log buffer to clipboard.", () => this.copyLogsToClipboard()),
432
+ action("F", `Filter: ${this.logFilter}`, "Cycle log filter: all, calls, errors, station, CSMS-in.", () => this.cycleLogFilter()),
433
+ action("G", "Jump to bottom", "Scroll log view to the latest entry.", () => this.scrollLogToEnd()),
434
+ action("c", "Clear log buffer", "Clear in-memory TUI log buffer.", () => this.clearLogs())
435
+ ];
436
+ case "scenarios":
437
+ return [
438
+ action("o", "Charge session", "Plug EV, Authorize, StartTransaction, and send MeterValues.", () => this.chargeSessionScenario(), () => Boolean(this.selectedConnector()?.transactionId)),
439
+ action("w", "Finish session", "Send MeterValues, StopTransaction, and unplug EV.", () => this.finishSessionScenario(), () => !this.selectedConnector()?.transactionId),
440
+ action("O", "Offline start/stop sync", "Disconnect, run local start/stop, then reconnect for sync.", () => this.offlineSyncScenario()),
441
+ action("i", "Fault recovery", "Inject a fault on the selected connector, then clear it.", () => this.faultRecoveryScenario())
442
+ ];
443
+ }
444
+ }
445
+ runSelectedAction() {
446
+ if (this.modal || this.plainLogView) {
447
+ return;
448
+ }
449
+ const action = this.actions[this.selectedActionIndex()];
450
+ if (!action) {
451
+ return;
452
+ }
453
+ this.runAction(action);
454
+ }
455
+ bindActionAccelerators() {
456
+ this.screen.key([
457
+ "a",
458
+ "b",
459
+ "c",
460
+ "d",
461
+ "D",
462
+ "S-d",
463
+ "e",
464
+ "f",
465
+ "h",
466
+ "i",
467
+ "m",
468
+ "M",
469
+ "S-m",
470
+ "n",
471
+ "o",
472
+ "O",
473
+ "S-o",
474
+ "p",
475
+ "s",
476
+ "t",
477
+ "u",
478
+ "U",
479
+ "S-u",
480
+ "v",
481
+ "w",
482
+ "x"
483
+ ], (_ch, key) => this.runActionAccelerator(key.full ?? key.name));
484
+ }
485
+ runActionAccelerator(keyName) {
486
+ if (this.modal || this.plainLogView) {
487
+ return;
488
+ }
489
+ const key = normalizeActionKey(keyName);
490
+ const currentAction = this.actions.find((item) => item.key === key);
491
+ if (currentAction) {
492
+ this.runAction(currentAction);
493
+ return;
494
+ }
495
+ const tab = legacyActionTab(key);
496
+ if (!tab) {
497
+ return;
498
+ }
499
+ const action = this.actionsForTab(tab).find((item) => item.key === key);
500
+ if (!action) {
501
+ return;
502
+ }
503
+ this.setTab(tab);
504
+ const index = this.actions.findIndex((item) => item.key === key);
505
+ if (index >= 0) {
506
+ this.actionList.select(index);
507
+ }
508
+ this.runAction(action);
509
+ }
510
+ runAction(action) {
511
+ if (action.disabled?.()) {
512
+ this.renderSyntheticLog("warn", `${action.label} is not available right now`);
513
+ return;
514
+ }
515
+ action.run();
516
+ }
517
+ runWithoutOverlay(callback) {
518
+ if (this.modal || this.plainLogView) {
519
+ return;
520
+ }
521
+ callback();
522
+ }
523
+ runWithoutModal(callback) {
524
+ if (this.modal) {
525
+ return;
526
+ }
527
+ callback();
528
+ }
529
+ openCommandPalette() {
530
+ if (this.modal || this.plainLogView) {
531
+ return;
532
+ }
533
+ const width = Math.min(Math.max(54, this.screenColumns() - 10), 96);
534
+ const height = Math.min(18, Math.max(10, this.screenRows() - 6));
535
+ const box = blessed.box({
536
+ top: "center",
537
+ left: "center",
538
+ width,
539
+ height,
540
+ border: "line",
541
+ label: " Commands ",
542
+ tags: true,
543
+ keys: true,
544
+ style: {
545
+ border: { fg: THEME.panelBorder }
546
+ }
547
+ });
548
+ const input = blessed.box({
549
+ parent: box,
550
+ top: 1,
551
+ left: 2,
552
+ right: 2,
553
+ height: 3,
554
+ border: "line",
555
+ keys: true,
556
+ tags: true,
557
+ style: {
558
+ border: { fg: THEME.panelBorder }
559
+ }
560
+ });
561
+ const list = blessed.list({
562
+ parent: box,
563
+ top: 4,
564
+ left: 2,
565
+ right: 2,
566
+ bottom: 2,
567
+ border: "line",
568
+ tags: true,
569
+ keys: true,
570
+ vi: false,
571
+ items: [],
572
+ style: {
573
+ selected: { bg: THEME.selectedBg, fg: THEME.selectedFg },
574
+ border: { fg: THEME.panelBorder }
575
+ }
576
+ });
577
+ const help = blessed.box({
578
+ parent: box,
579
+ bottom: 0,
580
+ left: 2,
581
+ right: 2,
582
+ height: 1,
583
+ tags: true,
584
+ content: "{gray-fg}Type to filter Up/Down select Enter run Esc cancel{/gray-fg}"
585
+ });
586
+ let entries = [];
587
+ let selectedIndex = 0;
588
+ let closed = false;
589
+ let query = "";
590
+ const move = (offset) => {
591
+ if (entries.length === 0) {
592
+ return;
593
+ }
594
+ selectedIndex = clamp(selectedIndex + offset, 0, entries.length - 1);
595
+ list.select(selectedIndex);
596
+ this.screen.render();
597
+ };
598
+ const renderSearch = () => {
599
+ input.setContent(`${truncateText(query, Math.max(1, inputContentWidth(input) - 1))}{inverse} {/inverse}`);
600
+ };
601
+ const updateResults = () => {
602
+ if (closed) {
603
+ return;
604
+ }
605
+ const trimmedQuery = query.trim();
606
+ renderSearch();
607
+ entries = this.paletteEntries(trimmedQuery);
608
+ selectedIndex = clamp(selectedIndex, 0, Math.max(0, entries.length - 1));
609
+ box.setLabel(` Commands${trimmedQuery ? `: ${truncateText(trimmedQuery, 28)}` : ""} `);
610
+ if (entries.length === 0) {
611
+ list.setItems(["{gray-fg}No matching commands{/gray-fg}"]);
612
+ list.select(0);
613
+ this.screen.render();
614
+ return;
615
+ }
616
+ list.setItems(entries.map((entry) => this.paletteEntryLine(entry)));
617
+ list.select(selectedIndex);
618
+ this.screen.render();
619
+ };
620
+ const close = (entry) => {
621
+ closed = true;
622
+ box.destroy();
623
+ this.modal = undefined;
624
+ this.actionList.focus();
625
+ this.screen.render();
626
+ if (!entry) {
627
+ return;
628
+ }
629
+ this.setTab(entry.tab);
630
+ this.actionList.select(entry.actionIndex);
631
+ this.runAction(entry.action);
632
+ };
633
+ const handleSearchKey = (ch, key) => {
634
+ if (closed) {
635
+ return;
636
+ }
637
+ if (key.name === "enter") {
638
+ close(entries[selectedIndex]);
639
+ return;
640
+ }
641
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
642
+ close();
643
+ return;
644
+ }
645
+ if (key.name === "up") {
646
+ move(-1);
647
+ return;
648
+ }
649
+ if (key.name === "down" || key.name === "tab") {
650
+ move(1);
651
+ return;
652
+ }
653
+ if (key.name === "backspace") {
654
+ query = query.slice(0, -1);
655
+ selectedIndex = 0;
656
+ updateResults();
657
+ return;
658
+ }
659
+ if (key.name === "delete") {
660
+ query = "";
661
+ selectedIndex = 0;
662
+ updateResults();
663
+ return;
664
+ }
665
+ if (ch && !key.ctrl && !key.meta && isPrintableInput(ch)) {
666
+ query += ch;
667
+ selectedIndex = 0;
668
+ updateResults();
669
+ }
670
+ };
671
+ this.modal = box;
672
+ input.on("keypress", handleSearchKey);
673
+ box.key(["up"], () => move(-1));
674
+ box.key(["down"], () => move(1));
675
+ box.key(["escape", "C-c"], () => close());
676
+ box.key("enter", () => close(entries[selectedIndex]));
677
+ box.append(help);
678
+ this.screen.append(box);
679
+ box.setFront();
680
+ input.focus();
681
+ updateResults();
682
+ this.screen.render();
683
+ }
684
+ openHelp() {
685
+ const width = Math.min(92, Math.max(58, this.screenColumns() - 8));
686
+ const height = Math.min(24, Math.max(16, this.screenRows() - 4));
687
+ const box = blessed.box({
688
+ top: "center",
689
+ left: "center",
690
+ width,
691
+ height,
692
+ border: "line",
693
+ label: " Help ",
694
+ tags: true,
695
+ keys: true,
696
+ scrollable: true,
697
+ alwaysScroll: false,
698
+ padding: { left: 2, right: 2 },
699
+ style: {
700
+ border: { fg: THEME.panelBorder }
701
+ },
702
+ scrollbar: {
703
+ ch: " ",
704
+ style: { bg: THEME.panelBorder }
705
+ }
706
+ });
707
+ box.setContent([
708
+ "{bold}Navigation{/bold}",
709
+ " 1-7 tabs Tab/S-Tab tab cycle Up/Down action Enter run",
710
+ " [/] connector + add connector / command search ? help",
711
+ "",
712
+ "{bold}Logs{/bold}",
713
+ " j/k scroll G bottom F filter l plain log y copy",
714
+ "",
715
+ "{bold}Forms{/bold}",
716
+ " Tab/Up/Down field Left/Right cursor Home/End jump",
717
+ " Backspace/Delete edit Ctrl+U clear Enter submit Esc cancel",
718
+ "",
719
+ "{bold}Station{/bold}",
720
+ ` id=${this.station.config.chargePointId}`,
721
+ ` url=${this.station.config.centralSystemUrl}`,
722
+ "",
723
+ "{gray-fg}Esc or q closes this help.{/gray-fg}"
724
+ ].join("\n"));
725
+ const close = () => {
726
+ box.destroy();
727
+ this.modal = undefined;
728
+ this.actionList.focus();
729
+ this.screen.render();
730
+ };
731
+ this.modal = box;
732
+ box.key(["escape", "q", "C-c"], close);
733
+ this.screen.append(box);
734
+ box.setFront();
735
+ box.focus();
736
+ this.screen.render();
737
+ }
738
+ paletteEntryLine(entry) {
739
+ const tab = this.tabLabel(entry.tab).padEnd(11);
740
+ const key = entry.action.key.padEnd(2);
741
+ const state = entry.action.disabled?.() ? "{gray-fg}" : "";
742
+ const endState = entry.action.disabled?.() ? "{/gray-fg}" : "";
743
+ return `${state}${tab} ${key} ${entry.action.label}${endState}`;
744
+ }
745
+ paletteEntries(query) {
746
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
747
+ const entries = TABS.flatMap((tab) => this.actionsForTab(tab.id).map((action, actionIndex) => ({ tab: tab.id, action, actionIndex })));
748
+ if (terms.length === 0) {
749
+ return entries;
750
+ }
751
+ return entries
752
+ .map((entry, originalIndex) => ({
753
+ entry,
754
+ originalIndex,
755
+ score: this.paletteEntryScore(entry, terms)
756
+ }))
757
+ .filter((item) => item.score < Number.POSITIVE_INFINITY)
758
+ .sort((left, right) => left.score - right.score || left.originalIndex - right.originalIndex)
759
+ .map((item) => item.entry);
760
+ }
761
+ paletteEntryScore(entry, terms) {
762
+ let score = 0;
763
+ for (const term of terms) {
764
+ const termScore = Math.min(weightedMatchScore(entry.action.label, term, 0), weightedMatchScore(entry.action.key, term, 20), weightedMatchScore(entry.action.description, term, 80), weightedMatchScore(this.tabLabel(entry.tab), term, 160));
765
+ if (termScore === Number.POSITIVE_INFINITY) {
766
+ return Number.POSITIVE_INFINITY;
767
+ }
768
+ score += termScore;
769
+ }
770
+ return score + (entry.action.disabled?.() ? 30 : 0);
771
+ }
772
+ selectedActionIndex() {
773
+ const selected = this.actionList.selected ?? 0;
774
+ return clamp(selected, 0, Math.max(0, this.actions.length - 1));
775
+ }
776
+ setTab(tab) {
777
+ this.activeTab = tab;
778
+ this.actionList.select(0);
779
+ this.render();
780
+ }
781
+ nextTab() {
782
+ const index = TABS.findIndex((tab) => tab.id === this.activeTab);
783
+ this.setTab(TABS[(index + 1) % TABS.length]?.id ?? "station");
784
+ }
785
+ previousTab() {
786
+ const index = TABS.findIndex((tab) => tab.id === this.activeTab);
787
+ this.setTab(TABS[(index - 1 + TABS.length) % TABS.length]?.id ?? "station");
788
+ }
789
+ tabLabel(tab) {
790
+ return TABS.find((item) => item.id === tab)?.label ?? "Station";
791
+ }
792
+ togglePlug() {
793
+ const connector = this.selectedConnector();
794
+ this.runStationTask(() => connector?.evConnected || connector?.status === "Finishing"
795
+ ? this.station.unplug(this.selectedConnectorId)
796
+ : this.station.plugIn(this.selectedConnectorId));
797
+ }
798
+ stopTransactionFlow() {
799
+ this.promptChoice("StopTransaction reason", STOP_REASONS, (reason) => this.runStationTask(() => this.station.stopTransaction(this.selectedConnectorId, readStopReason(reason), this.selectedConnector()?.lastIdTag)));
800
+ }
801
+ startTransactionFlow() {
802
+ this.promptForm("StartTransaction", [
803
+ { name: "connectorId", label: "connectorId", initialValue: String(this.selectedConnectorId) },
804
+ { name: "idTag", label: "idTag", initialValue: "" }
805
+ ], (values) => {
806
+ const connectorId = this.readFormConnectorId(values.connectorId);
807
+ const idTag = values.idTag.trim();
808
+ if (connectorId === undefined || !idTag) {
809
+ this.renderSyntheticLog("error", "StartTransaction requires valid connectorId and idTag");
810
+ return;
811
+ }
812
+ this.runStationTask(() => this.station.startTransaction(connectorId, idTag));
813
+ });
814
+ }
815
+ customMeterValuesFlow() {
816
+ this.promptForm("MeterValues", [
817
+ { name: "connectorId", label: "connectorId", initialValue: String(this.selectedConnectorId) },
818
+ { name: "deltaWh", label: "delta Wh", initialValue: "120" },
819
+ { name: "measurands", label: "measurands", initialValue: DEFAULT_MEASURANDS }
820
+ ], (values) => {
821
+ const connectorId = this.readFormConnectorId(values.connectorId);
822
+ const deltaWh = Number.parseFloat(values.deltaWh);
823
+ if (connectorId === undefined || !Number.isFinite(deltaWh)) {
824
+ this.renderSyntheticLog("error", "MeterValues requires valid connectorId and numeric deltaWh");
825
+ return;
826
+ }
827
+ this.runStationTask(() => this.station.meterValues(connectorId, deltaWh, values.measurands.trim()));
828
+ });
829
+ }
830
+ dataTransferFlow() {
831
+ this.promptForm("DataTransfer", [
832
+ { name: "vendorId", label: "vendorId", initialValue: this.station.config.vendor },
833
+ { name: "messageId", label: "messageId", initialValue: "" },
834
+ { name: "data", label: "data JSON/string", initialValue: JSON.stringify({ ping: true }) }
835
+ ], (values) => {
836
+ const vendorId = values.vendorId.trim();
837
+ if (!vendorId) {
838
+ this.renderSyntheticLog("error", "DataTransfer requires vendorId");
839
+ return;
840
+ }
841
+ const payload = { vendorId };
842
+ const messageId = values.messageId.trim();
843
+ const data = values.data.trim();
844
+ if (messageId) {
845
+ payload.messageId = messageId;
846
+ }
847
+ if (data) {
848
+ payload.data = parseJsonOrString(data);
849
+ }
850
+ this.runStationTask(() => this.station.dataTransfer(payload));
851
+ });
852
+ }
853
+ readFormConnectorId(value) {
854
+ const connectorId = Number.parseInt(value.trim(), 10);
855
+ if (!Number.isInteger(connectorId) || !this.station.state.connectors.some((connector) => connector.id === connectorId)) {
856
+ this.renderSyntheticLog("error", `Unknown connectorId: ${value}`);
857
+ return undefined;
858
+ }
859
+ return connectorId;
860
+ }
861
+ faultFlow() {
862
+ const connector = this.selectedConnector();
863
+ if (connector?.status === "Faulted") {
864
+ this.runStationTask(() => this.station.clearFault(this.selectedConnectorId));
865
+ return;
866
+ }
867
+ this.promptChoice("Fault errorCode", CHARGE_POINT_ERROR_CODES.filter((code) => code !== "NoError"), (errorCode) => this.runStationTask(() => this.station.setFault(this.selectedConnectorId, readChargePointErrorCode(errorCode))));
868
+ }
869
+ chargeSessionScenario() {
870
+ const connectorId = this.selectedConnectorId;
871
+ this.promptText(`Charge session idTag (#${connectorId})`, "", (idTag) => this.runStationTask(async () => {
872
+ this.renderSyntheticLog("info", `Scenario charge session started on connector ${connectorId}`);
873
+ await this.station.plugIn(connectorId);
874
+ await this.station.authorize(idTag);
875
+ const transactionId = await this.station.startTransaction(connectorId, idTag);
876
+ if (transactionId) {
877
+ await this.station.meterValues(connectorId);
878
+ }
879
+ }));
880
+ }
881
+ finishSessionScenario() {
882
+ const connectorId = this.selectedConnectorId;
883
+ const idTag = this.selectedConnector()?.lastIdTag;
884
+ this.promptChoice("Finish reason", STOP_REASONS, (reason) => this.runStationTask(async () => {
885
+ this.renderSyntheticLog("info", `Scenario finish session started on connector ${connectorId}`);
886
+ await this.station.meterValues(connectorId);
887
+ await this.station.stopTransaction(connectorId, readStopReason(reason), idTag);
888
+ await this.station.unplug(connectorId);
889
+ }));
890
+ }
891
+ offlineSyncScenario() {
892
+ const connectorId = this.selectedConnectorId;
893
+ this.promptText(`Offline sync idTag (#${connectorId})`, "", (idTag) => this.runStationTask(async () => {
894
+ this.renderSyntheticLog("info", `Scenario offline sync started on connector ${connectorId}`);
895
+ this.station.disconnect();
896
+ await this.station.plugIn(connectorId);
897
+ const transactionId = await this.station.startTransaction(connectorId, idTag);
898
+ if (transactionId) {
899
+ await this.station.meterValues(connectorId);
900
+ await this.station.stopTransaction(connectorId, "Local", idTag);
901
+ }
902
+ this.station.reconnect();
903
+ }));
904
+ }
905
+ faultRecoveryScenario() {
906
+ const connectorId = this.selectedConnectorId;
907
+ this.promptChoice("Fault recovery errorCode", CHARGE_POINT_ERROR_CODES.filter((code) => code !== "NoError"), (errorCode) => this.runStationTask(async () => {
908
+ this.renderSyntheticLog("info", `Scenario fault recovery started on connector ${connectorId}`);
909
+ await this.station.setFault(connectorId, readChargePointErrorCode(errorCode));
910
+ await this.station.clearFault(connectorId);
911
+ }));
912
+ }
913
+ addConnector() {
914
+ this.selectedConnectorId = this.station.addConnector();
915
+ this.render();
916
+ }
917
+ selectPreviousConnector() {
918
+ const ids = this.station.state.connectors.map((connector) => connector.id);
919
+ const currentIndex = ids.indexOf(this.selectedConnectorId);
920
+ this.selectedConnectorId = ids[Math.max(0, currentIndex - 1)] ?? this.selectedConnectorId;
921
+ this.render();
922
+ }
923
+ selectNextConnector() {
924
+ const ids = this.station.state.connectors.map((connector) => connector.id);
925
+ const currentIndex = ids.indexOf(this.selectedConnectorId);
926
+ this.selectedConnectorId = ids[Math.min(ids.length - 1, currentIndex + 1)] ?? this.selectedConnectorId;
927
+ this.render();
928
+ }
929
+ ensureSelectedConnector() {
930
+ if (this.station.state.connectors.some((connector) => connector.id === this.selectedConnectorId)) {
931
+ return;
932
+ }
933
+ this.selectedConnectorId = this.station.state.connectors[0]?.id ?? this.station.config.connectorId;
934
+ }
935
+ selectedConnector(state = this.station.state) {
936
+ return state.connectors.find((connector) => connector.id === this.selectedConnectorId) ?? state.connectors[0];
937
+ }
938
+ promptText(label, initialValue, callback) {
939
+ this.promptForm(label, [{ name: "value", label, initialValue }], (values) => {
940
+ const value = values.value.trim();
941
+ if (value) {
942
+ callback(value);
943
+ }
944
+ });
945
+ }
946
+ promptForm(label, fields, callback) {
947
+ const width = Math.min(86, Math.max(54, this.screenColumns() - 8));
948
+ const height = Math.min(this.screenRows() - 2, Math.max(10, fields.length * 4 + 4));
949
+ const box = blessed.box({
950
+ top: "center",
951
+ left: "center",
952
+ width,
953
+ height,
954
+ border: "line",
955
+ label: ` ${label} `,
956
+ tags: true,
957
+ keys: true,
958
+ style: {
959
+ border: { fg: THEME.panelBorder }
960
+ }
961
+ });
962
+ const values = fields.map((field) => field.initialValue);
963
+ const cursors = values.map((value) => value.length);
964
+ const inputBoxes = fields.map((field, index) => blessed.box({
965
+ parent: box,
966
+ top: 1 + index * 4,
967
+ left: 2,
968
+ right: 2,
969
+ height: 3,
970
+ border: "line",
971
+ label: ` ${field.label} `,
972
+ keys: true,
973
+ tags: true,
974
+ style: {
975
+ border: { fg: THEME.panelBorder }
976
+ }
977
+ }));
978
+ const help = blessed.box({
979
+ parent: box,
980
+ bottom: 0,
981
+ left: 2,
982
+ right: 2,
983
+ height: 1,
984
+ tags: true,
985
+ content: "{gray-fg}Tab/Up/Down field Left/Right cursor C-u clear Enter submit Esc cancel{/gray-fg}"
986
+ });
987
+ let focusedIndex = 0;
988
+ let closed = false;
989
+ const renderInput = (index) => {
990
+ const input = inputBoxes[index];
991
+ if (!input) {
992
+ return;
993
+ }
994
+ const width = inputContentWidth(input);
995
+ const value = values[index] ?? "";
996
+ const cursor = clamp(cursors[index] ?? value.length, 0, value.length);
997
+ input.setContent(renderFieldValue(value, cursor, width, focusedIndex === index));
998
+ input.style.border = { fg: focusedIndex === index ? "yellow" : THEME.panelBorder };
999
+ };
1000
+ const renderInputs = () => {
1001
+ inputBoxes.forEach((_input, index) => renderInput(index));
1002
+ };
1003
+ const focusInput = (index) => {
1004
+ focusedIndex = clamp(index, 0, inputBoxes.length - 1);
1005
+ renderInputs();
1006
+ inputBoxes[focusedIndex]?.focus();
1007
+ this.screen.render();
1008
+ };
1009
+ const close = (submit) => {
1010
+ if (closed) {
1011
+ return;
1012
+ }
1013
+ closed = true;
1014
+ const submittedValues = Object.fromEntries(fields.map((field, index) => [field.name, values[index]?.trim() ?? ""]));
1015
+ box.destroy();
1016
+ this.modal = undefined;
1017
+ this.actionList.focus();
1018
+ this.screen.render();
1019
+ if (submit) {
1020
+ callback(submittedValues);
1021
+ }
1022
+ };
1023
+ const handleKey = (index, ch, key) => {
1024
+ if (closed) {
1025
+ return;
1026
+ }
1027
+ if (key.name === "enter") {
1028
+ close(true);
1029
+ return;
1030
+ }
1031
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
1032
+ close(false);
1033
+ return;
1034
+ }
1035
+ if (key.name === "tab" || key.name === "down") {
1036
+ focusInput(index + 1);
1037
+ return;
1038
+ }
1039
+ if (key.name === "up" || (key.shift && key.name === "tab")) {
1040
+ focusInput(index - 1);
1041
+ return;
1042
+ }
1043
+ if (key.name === "backspace") {
1044
+ const cursor = cursors[index] ?? 0;
1045
+ if (cursor > 0) {
1046
+ values[index] = `${(values[index] ?? "").slice(0, cursor - 1)}${(values[index] ?? "").slice(cursor)}`;
1047
+ cursors[index] = cursor - 1;
1048
+ }
1049
+ renderInput(index);
1050
+ this.screen.render();
1051
+ return;
1052
+ }
1053
+ if (key.name === "delete") {
1054
+ const value = values[index] ?? "";
1055
+ const cursor = cursors[index] ?? 0;
1056
+ if (cursor < value.length) {
1057
+ values[index] = `${value.slice(0, cursor)}${value.slice(cursor + 1)}`;
1058
+ }
1059
+ renderInput(index);
1060
+ this.screen.render();
1061
+ return;
1062
+ }
1063
+ if (key.ctrl && key.name === "u") {
1064
+ values[index] = "";
1065
+ cursors[index] = 0;
1066
+ renderInput(index);
1067
+ this.screen.render();
1068
+ return;
1069
+ }
1070
+ if (key.name === "left") {
1071
+ cursors[index] = clamp((cursors[index] ?? 0) - 1, 0, (values[index] ?? "").length);
1072
+ renderInput(index);
1073
+ this.screen.render();
1074
+ return;
1075
+ }
1076
+ if (key.name === "right") {
1077
+ cursors[index] = clamp((cursors[index] ?? 0) + 1, 0, (values[index] ?? "").length);
1078
+ renderInput(index);
1079
+ this.screen.render();
1080
+ return;
1081
+ }
1082
+ if (key.name === "home") {
1083
+ cursors[index] = 0;
1084
+ renderInput(index);
1085
+ this.screen.render();
1086
+ return;
1087
+ }
1088
+ if (key.name === "end") {
1089
+ cursors[index] = (values[index] ?? "").length;
1090
+ renderInput(index);
1091
+ this.screen.render();
1092
+ return;
1093
+ }
1094
+ if (ch && !key.ctrl && !key.meta && isPrintableInput(ch)) {
1095
+ const value = values[index] ?? "";
1096
+ const cursor = cursors[index] ?? value.length;
1097
+ values[index] = `${value.slice(0, cursor)}${ch}${value.slice(cursor)}`;
1098
+ cursors[index] = cursor + ch.length;
1099
+ renderInput(index);
1100
+ this.screen.render();
1101
+ }
1102
+ };
1103
+ this.modal = box;
1104
+ for (const [index, input] of inputBoxes.entries()) {
1105
+ input.on("keypress", (ch, key) => handleKey(index, ch, key));
1106
+ }
1107
+ box.key(["escape", "C-c"], () => close(false));
1108
+ box.key(["tab"], () => focusInput(focusedIndex + 1));
1109
+ box.key(["S-tab"], () => focusInput(focusedIndex - 1));
1110
+ box.append(help);
1111
+ this.screen.append(box);
1112
+ box.setFront();
1113
+ renderInputs();
1114
+ focusInput(0);
1115
+ }
1116
+ promptChoice(label, choices, callback) {
1117
+ const width = Math.max(36, Math.min(72, Math.max(...choices.map((choice) => choice.length)) + 8));
1118
+ const list = blessed.list({
1119
+ parent: this.screen,
1120
+ top: "center",
1121
+ left: "center",
1122
+ width,
1123
+ height: Math.min(choices.length + 4, 16),
1124
+ border: "line",
1125
+ label: ` ${label} `,
1126
+ tags: true,
1127
+ keys: true,
1128
+ vi: true,
1129
+ items: choices.map((choice) => ` ${choice}`),
1130
+ style: {
1131
+ selected: { bg: THEME.selectedBg, fg: THEME.selectedFg },
1132
+ border: { fg: THEME.panelBorder }
1133
+ }
1134
+ });
1135
+ this.modal = list;
1136
+ const close = (value) => {
1137
+ list.destroy();
1138
+ this.modal = undefined;
1139
+ this.actionList.focus();
1140
+ this.screen.render();
1141
+ if (value) {
1142
+ callback(value);
1143
+ }
1144
+ };
1145
+ list.key(["escape", "q"], () => close());
1146
+ list.key("enter", () => close(choices[list.selected ?? 0]));
1147
+ list.focus();
1148
+ this.screen.render();
1149
+ }
1150
+ closeModal() {
1151
+ if (!this.modal) {
1152
+ return false;
1153
+ }
1154
+ this.modal.destroy();
1155
+ this.modal = undefined;
1156
+ this.actionList.focus();
1157
+ this.screen.render();
1158
+ return true;
1159
+ }
1160
+ runStationTask(task) {
1161
+ task().catch((error) => {
1162
+ const message = error instanceof Error ? error.message : String(error);
1163
+ this.renderSyntheticLog("error", message);
1164
+ });
1165
+ }
1166
+ renderLog(entry) {
1167
+ this.logs.push(entry);
1168
+ if (this.logs.length > 800) {
1169
+ this.logs.shift();
1170
+ }
1171
+ if (this.matchesLogFilter(entry)) {
1172
+ this.appendLogEntry(entry);
1173
+ }
1174
+ this.refreshPlainLogView();
1175
+ this.screen.render();
1176
+ }
1177
+ renderSyntheticLog(level, message) {
1178
+ this.renderLog({ at: new Date(), level, message });
1179
+ }
1180
+ scrollLog(offset) {
1181
+ const target = this.plainLogView ?? this.logBox;
1182
+ target.scroll(offset);
1183
+ this.screen.render();
1184
+ }
1185
+ scrollLogToEnd() {
1186
+ const target = this.plainLogView ?? this.logBox;
1187
+ target.setScrollPerc(100);
1188
+ this.screen.render();
1189
+ }
1190
+ openPlainLogView() {
1191
+ if (this.plainLogView) {
1192
+ this.plainLogView.focus();
1193
+ return;
1194
+ }
1195
+ this.plainLogView = blessed.box({
1196
+ top: 0,
1197
+ left: 0,
1198
+ width: "100%",
1199
+ height: "100%",
1200
+ tags: false,
1201
+ keys: true,
1202
+ vi: true,
1203
+ mouse: false,
1204
+ scrollable: true,
1205
+ alwaysScroll: false,
1206
+ content: this.plainLogContent(),
1207
+ style: {
1208
+ fg: "white",
1209
+ bg: "black"
1210
+ },
1211
+ scrollbar: {
1212
+ ch: " ",
1213
+ style: { bg: "cyan" }
1214
+ }
1215
+ });
1216
+ this.bindLogNavigationKeys(this.plainLogView);
1217
+ this.screen.append(this.plainLogView);
1218
+ this.plainLogView.setFront();
1219
+ this.plainLogView.focus();
1220
+ this.plainLogView.setScrollPerc(100);
1221
+ this.screen.render();
1222
+ }
1223
+ closePlainLogView() {
1224
+ if (!this.plainLogView) {
1225
+ return false;
1226
+ }
1227
+ this.plainLogView.destroy();
1228
+ this.plainLogView = undefined;
1229
+ this.actionList.focus();
1230
+ this.screen.render();
1231
+ return true;
1232
+ }
1233
+ refreshPlainLogView() {
1234
+ if (!this.plainLogView) {
1235
+ return;
1236
+ }
1237
+ const scrollPercent = this.plainLogView.getScrollPerc();
1238
+ this.plainLogView.setContent(this.plainLogContent());
1239
+ if (scrollPercent >= 99) {
1240
+ this.plainLogView.setScrollPerc(100);
1241
+ }
1242
+ }
1243
+ copyLogsToClipboard() {
1244
+ const filtered = this.filteredLogs();
1245
+ const text = filtered.map((entry) => this.formatLogEntryForClipboard(entry)).join("\n");
1246
+ if (!text) {
1247
+ this.renderSyntheticLog("warn", "Log is empty");
1248
+ return;
1249
+ }
1250
+ const copied = copyToClipboard(text);
1251
+ this.renderSyntheticLog(copied ? "success" : "error", copied ? `Copied ${filtered.length} filtered log entries to clipboard` : "Could not copy logs to clipboard");
1252
+ }
1253
+ clearLogs() {
1254
+ this.logs.splice(0, this.logs.length);
1255
+ this.logBox.setContent("");
1256
+ this.refreshPlainLogView();
1257
+ this.renderSyntheticLog("info", "Log buffer cleared");
1258
+ }
1259
+ cycleLogFilter() {
1260
+ const filters = ["all", "calls", "errors", "station", "csms"];
1261
+ const current = filters.indexOf(this.logFilter);
1262
+ this.logFilter = filters[(current + 1) % filters.length] ?? "all";
1263
+ this.renderLogBuffer();
1264
+ this.render();
1265
+ this.renderSyntheticLog("info", `Log filter: ${this.logFilter}`);
1266
+ }
1267
+ renderLogBuffer() {
1268
+ this.logBox.setLabel(` OCPP log [${this.logFilter}] `);
1269
+ this.logBox.setContent("");
1270
+ for (const entry of this.filteredLogs()) {
1271
+ this.appendLogEntry(entry);
1272
+ }
1273
+ this.scrollLogToEnd();
1274
+ }
1275
+ appendLogEntry(entry) {
1276
+ this.logBox.log(this.formatLogEntryForTui(entry));
1277
+ if (entry.details) {
1278
+ this.logBox.log(` {gray-fg}${entry.details}{/gray-fg}`);
1279
+ }
1280
+ }
1281
+ filteredLogs() {
1282
+ return this.logs.filter((entry) => this.matchesLogFilter(entry));
1283
+ }
1284
+ matchesLogFilter(entry) {
1285
+ switch (this.logFilter) {
1286
+ case "all":
1287
+ return true;
1288
+ case "calls":
1289
+ return entry.level === "in" || entry.level === "out";
1290
+ case "errors":
1291
+ return entry.level === "warn" || entry.level === "error";
1292
+ case "station":
1293
+ return entry.level === "info" || entry.level === "success" || entry.level === "warn" || entry.level === "error";
1294
+ case "csms":
1295
+ return entry.level === "in";
1296
+ }
1297
+ }
1298
+ formatLogEntryForTui(entry) {
1299
+ const time = entry.at.toLocaleTimeString();
1300
+ const level = LEVEL_STYLE[entry.level];
1301
+ return `${time} ${level} ${entry.message}`;
1302
+ }
1303
+ formatLogEntryForClipboard(entry) {
1304
+ const line = `${entry.at.toISOString()} ${entry.level.toUpperCase()} ${entry.message}`;
1305
+ return entry.details ? `${line}\n ${entry.details}` : line;
1306
+ }
1307
+ plainLogContent() {
1308
+ const lines = this.filteredLogs().map((entry) => this.formatLogEntryForClipboard(entry));
1309
+ return [
1310
+ `OCPP log [${this.logFilter}] - j/k scroll, G bottom, y copy, F filter, Esc close`,
1311
+ "",
1312
+ ...lines
1313
+ ].join("\n");
1314
+ }
1315
+ bindLogNavigationKeys(widget) {
1316
+ widget.key(["g", "G", "S-g", "end"], () => this.scrollLogToEnd());
1317
+ widget.key(["j", "down"], () => this.scrollLog(1));
1318
+ widget.key(["k", "up"], () => this.scrollLog(-1));
1319
+ widget.key(["y"], () => this.copyLogsToClipboard());
1320
+ }
1321
+ stopAndExit() {
1322
+ this.stop();
1323
+ process.exit(0);
1324
+ }
1325
+ screenColumns() {
1326
+ return typeof this.screen.width === "number" ? this.screen.width : 120;
1327
+ }
1328
+ screenRows() {
1329
+ return typeof this.screen.height === "number" ? this.screen.height : 40;
1330
+ }
1331
+ connectorContentWidth() {
1332
+ return Math.max(18, Math.floor(this.screenColumns() * (this.screenColumns() < 110 ? 0.32 : 0.25)) - 4);
1333
+ }
1334
+ centerContentWidth() {
1335
+ return Math.max(28, Math.floor(this.screenColumns() * (this.screenColumns() < 110 ? 0.68 : 0.37)) - 4);
1336
+ }
1337
+ connectorContentRows() {
1338
+ const height = this.connectorList.height;
1339
+ const rows = typeof height === "number" ? height : this.screenRows() - 6;
1340
+ return Math.max(1, rows - 2);
1341
+ }
1342
+ }
1343
+ function action(key, label, description, run, disabled) {
1344
+ return { key, label, description, run, disabled };
1345
+ }
1346
+ function row(label, value) {
1347
+ return `${label.padEnd(13)} ${value}`;
1348
+ }
1349
+ function shortConnectorStatus(status) {
1350
+ switch (status) {
1351
+ case "Available":
1352
+ return "Avail";
1353
+ case "Preparing":
1354
+ return "Prep";
1355
+ case "Charging":
1356
+ return "Charge";
1357
+ case "SuspendedEV":
1358
+ return "SuspEV";
1359
+ case "SuspendedEVSE":
1360
+ return "SuspSE";
1361
+ case "Finishing":
1362
+ return "Finish";
1363
+ case "Reserved":
1364
+ return "Reserv";
1365
+ case "Unavailable":
1366
+ return "Unavail";
1367
+ case "Faulted":
1368
+ return "Fault";
1369
+ }
1370
+ }
1371
+ function shortErrorCode(errorCode) {
1372
+ if (errorCode === "NoError") {
1373
+ return "Fault";
1374
+ }
1375
+ if (errorCode === "GroundFailure") {
1376
+ return "Ground";
1377
+ }
1378
+ if (errorCode === "PowerMeterFailure") {
1379
+ return "Meter";
1380
+ }
1381
+ if (errorCode === "PowerSwitchFailure") {
1382
+ return "Switch";
1383
+ }
1384
+ if (errorCode === "ReaderFailure") {
1385
+ return "Reader";
1386
+ }
1387
+ if (errorCode === "ResetFailure") {
1388
+ return "Reset";
1389
+ }
1390
+ if (errorCode === "UnderVoltage") {
1391
+ return "UnderV";
1392
+ }
1393
+ if (errorCode === "OverVoltage") {
1394
+ return "OverV";
1395
+ }
1396
+ if (errorCode === "WeakSignal") {
1397
+ return "Signal";
1398
+ }
1399
+ return errorCode;
1400
+ }
1401
+ function copyToClipboard(text) {
1402
+ const command = clipboardCommand();
1403
+ if (!command) {
1404
+ return false;
1405
+ }
1406
+ const result = spawnSync(command.command, command.args, {
1407
+ input: text,
1408
+ stdio: ["pipe", "ignore", "ignore"]
1409
+ });
1410
+ return result.status === 0;
1411
+ }
1412
+ function clipboardCommand() {
1413
+ if (process.platform === "darwin") {
1414
+ return { command: "pbcopy", args: [] };
1415
+ }
1416
+ if (process.platform === "win32") {
1417
+ return { command: "clip", args: [] };
1418
+ }
1419
+ for (const candidate of [
1420
+ { command: "wl-copy", args: [] },
1421
+ { command: "xclip", args: ["-selection", "clipboard"] },
1422
+ { command: "xsel", args: ["--clipboard", "--input"] }
1423
+ ]) {
1424
+ const result = spawnSync(candidate.command, ["--version"], { stdio: "ignore" });
1425
+ if (result.status === 0) {
1426
+ return candidate;
1427
+ }
1428
+ }
1429
+ return undefined;
1430
+ }
1431
+ function readStopReason(value) {
1432
+ const normalized = value.trim();
1433
+ return isStopReason(normalized) ? normalized : "Local";
1434
+ }
1435
+ function parseObjectJson(value) {
1436
+ try {
1437
+ const parsed = JSON.parse(value);
1438
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
1439
+ }
1440
+ catch {
1441
+ return undefined;
1442
+ }
1443
+ }
1444
+ function parseJsonOrString(value) {
1445
+ try {
1446
+ return JSON.parse(value);
1447
+ }
1448
+ catch {
1449
+ return value;
1450
+ }
1451
+ }
1452
+ function truncateText(value, maxLength) {
1453
+ if (value.length <= maxLength) {
1454
+ return value;
1455
+ }
1456
+ return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
1457
+ }
1458
+ function truncateTaggedLine(value, maxLength) {
1459
+ const strippedLength = value.replace(/\{[^}]+}/g, "").length;
1460
+ if (strippedLength <= maxLength) {
1461
+ return value;
1462
+ }
1463
+ return truncateText(value, maxLength);
1464
+ }
1465
+ function inputContentWidth(input) {
1466
+ const width = typeof input.width === "number" ? input.width : 48;
1467
+ return Math.max(1, width - 4);
1468
+ }
1469
+ function renderFieldValue(value, cursor, width, focused) {
1470
+ const contentWidth = Math.max(1, width);
1471
+ if (!focused) {
1472
+ return truncateText(value, contentWidth);
1473
+ }
1474
+ const safeCursor = clamp(cursor, 0, value.length);
1475
+ const visibleCharacters = Math.max(1, contentWidth - 1);
1476
+ const start = clamp(safeCursor - visibleCharacters, 0, Math.max(0, value.length - visibleCharacters));
1477
+ const before = value.slice(start, safeCursor);
1478
+ const current = value[safeCursor] ?? " ";
1479
+ const after = value.slice(safeCursor + 1, start + visibleCharacters);
1480
+ const prefix = start > 0 ? "<" : "";
1481
+ const suffix = start + visibleCharacters < value.length ? ">" : "";
1482
+ return `${prefix}${before}{inverse}${current}{/inverse}${after}${suffix}`;
1483
+ }
1484
+ function isPrintableInput(value) {
1485
+ return value.length > 0 && !/[\u0000-\u001f\u007f]/.test(value);
1486
+ }
1487
+ function weightedMatchScore(value, term, base) {
1488
+ const normalized = value.toLowerCase();
1489
+ const index = normalized.indexOf(term);
1490
+ if (index < 0) {
1491
+ return Number.POSITIVE_INFINITY;
1492
+ }
1493
+ const boundaryBonus = index === 0 || /\s|\/|-/.test(normalized[index - 1] ?? "") ? 0 : 10;
1494
+ return base + boundaryBonus + index;
1495
+ }
1496
+ function clamp(value, min, max) {
1497
+ return Math.min(Math.max(value, min), max);
1498
+ }
1499
+ function normalizeActionKey(keyName) {
1500
+ if (keyName === "S-m") {
1501
+ return "M";
1502
+ }
1503
+ if (keyName === "S-d") {
1504
+ return "D";
1505
+ }
1506
+ if (keyName === "S-u") {
1507
+ return "U";
1508
+ }
1509
+ if (keyName === "S-o") {
1510
+ return "O";
1511
+ }
1512
+ return keyName ?? "";
1513
+ }
1514
+ function legacyActionTab(key) {
1515
+ if (["b", "h", "x"].includes(key)) {
1516
+ return "station";
1517
+ }
1518
+ if (["p", "s", "n", "f"].includes(key)) {
1519
+ return "connector";
1520
+ }
1521
+ if (["a", "t", "e", "m", "M"].includes(key)) {
1522
+ return "transaction";
1523
+ }
1524
+ if (["D", "u", "U"].includes(key)) {
1525
+ return "maintenance";
1526
+ }
1527
+ if (["d", "v"].includes(key)) {
1528
+ return "data";
1529
+ }
1530
+ if (["c"].includes(key)) {
1531
+ return "logs";
1532
+ }
1533
+ if (["o", "O", "w", "i"].includes(key)) {
1534
+ return "scenarios";
1535
+ }
1536
+ return undefined;
1537
+ }
1538
+ function deleteRuntimeProperty(target, key) {
1539
+ delete target[key];
1540
+ }
1541
+ const STOP_REASONS = [
1542
+ "Local",
1543
+ "EVDisconnected",
1544
+ "DeAuthorized",
1545
+ "EmergencyStop",
1546
+ "HardReset",
1547
+ "Other",
1548
+ "PowerLoss",
1549
+ "Reboot",
1550
+ "Remote",
1551
+ "SoftReset",
1552
+ "UnlockCommand"
1553
+ ];
1554
+ function isStopReason(value) {
1555
+ return STOP_REASONS.includes(value);
1556
+ }
1557
+ const CHARGE_POINT_ERROR_CODES = [
1558
+ "ConnectorLockFailure",
1559
+ "EVCommunicationError",
1560
+ "GroundFailure",
1561
+ "HighTemperature",
1562
+ "InternalError",
1563
+ "LocalListConflict",
1564
+ "NoError",
1565
+ "OtherError",
1566
+ "OverCurrentFailure",
1567
+ "PowerMeterFailure",
1568
+ "PowerSwitchFailure",
1569
+ "ReaderFailure",
1570
+ "ResetFailure",
1571
+ "UnderVoltage",
1572
+ "OverVoltage",
1573
+ "WeakSignal"
1574
+ ];
1575
+ function readChargePointErrorCode(value) {
1576
+ const normalized = value.trim();
1577
+ return isChargePointErrorCode(normalized) && normalized !== "NoError" ? normalized : "OtherError";
1578
+ }
1579
+ function isChargePointErrorCode(value) {
1580
+ return CHARGE_POINT_ERROR_CODES.includes(value);
1581
+ }
1582
+ function readDiagnosticsOutcome(value) {
1583
+ return value.trim() === "uploadFailure" ? "uploadFailure" : "success";
1584
+ }
1585
+ function readFirmwareOutcome(value) {
1586
+ const normalized = value.trim();
1587
+ return normalized === "downloadFailure" || normalized === "installationFailure" ? normalized : "success";
1588
+ }
1589
+ function readDiagnosticsStatus(value) {
1590
+ return value === "Uploading" || value === "Uploaded" || value === "UploadFailed" ? value : "Idle";
1591
+ }
1592
+ function readFirmwareStatus(value) {
1593
+ return value === "Downloading" ||
1594
+ value === "Downloaded" ||
1595
+ value === "Installing" ||
1596
+ value === "Installed" ||
1597
+ value === "DownloadFailed" ||
1598
+ value === "InstallationFailed"
1599
+ ? value
1600
+ : "Idle";
1601
+ }