@ocpp-debugkit/toolkit 0.2.0 → 0.3.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 (123) hide show
  1. package/README.md +234 -12
  2. package/dist/cli/commands/anonymize.d.ts +15 -0
  3. package/dist/cli/commands/anonymize.d.ts.map +1 -0
  4. package/dist/cli/commands/anonymize.js +104 -0
  5. package/dist/cli/commands/anonymize.js.map +1 -0
  6. package/dist/cli/commands/ci.d.ts +10 -0
  7. package/dist/cli/commands/ci.d.ts.map +1 -0
  8. package/dist/cli/commands/ci.js +96 -0
  9. package/dist/cli/commands/ci.js.map +1 -0
  10. package/dist/cli/commands/diff.d.ts +10 -0
  11. package/dist/cli/commands/diff.d.ts.map +1 -0
  12. package/dist/cli/commands/diff.js +71 -0
  13. package/dist/cli/commands/diff.js.map +1 -0
  14. package/dist/cli/commands/report.d.ts.map +1 -1
  15. package/dist/cli/commands/report.js +6 -3
  16. package/dist/cli/commands/report.js.map +1 -1
  17. package/dist/cli/commands/scenario.d.ts +11 -3
  18. package/dist/cli/commands/scenario.d.ts.map +1 -1
  19. package/dist/cli/commands/scenario.js +41 -4
  20. package/dist/cli/commands/scenario.js.map +1 -1
  21. package/dist/cli/index.js +41 -5
  22. package/dist/cli/index.js.map +1 -1
  23. package/dist/core/__fixtures__/connector-fault.d.ts +6 -0
  24. package/dist/core/__fixtures__/connector-fault.d.ts.map +1 -1
  25. package/dist/core/__fixtures__/connector-fault.js +11 -1
  26. package/dist/core/__fixtures__/connector-fault.js.map +1 -1
  27. package/dist/core/__fixtures__/normal-session.d.ts +6 -0
  28. package/dist/core/__fixtures__/normal-session.d.ts.map +1 -1
  29. package/dist/core/__fixtures__/normal-session.js +10 -0
  30. package/dist/core/__fixtures__/normal-session.js.map +1 -1
  31. package/dist/core/assertions.d.ts +36 -0
  32. package/dist/core/assertions.d.ts.map +1 -0
  33. package/dist/core/assertions.js +335 -0
  34. package/dist/core/assertions.js.map +1 -0
  35. package/dist/core/detection.d.ts +20 -3
  36. package/dist/core/detection.d.ts.map +1 -1
  37. package/dist/core/detection.js +624 -6
  38. package/dist/core/detection.js.map +1 -1
  39. package/dist/core/diff.d.ts +68 -0
  40. package/dist/core/diff.d.ts.map +1 -0
  41. package/dist/core/diff.js +199 -0
  42. package/dist/core/diff.js.map +1 -0
  43. package/dist/core/fixtures/index.d.ts +12 -0
  44. package/dist/core/fixtures/index.d.ts.map +1 -1
  45. package/dist/core/index.d.ts +4 -0
  46. package/dist/core/index.d.ts.map +1 -1
  47. package/dist/core/index.js +4 -0
  48. package/dist/core/index.js.map +1 -1
  49. package/dist/core/types.d.ts +77 -2
  50. package/dist/core/types.d.ts.map +1 -1
  51. package/dist/index.d.ts +3 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +2 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/react/components.d.ts +9 -45
  56. package/dist/react/components.d.ts.map +1 -1
  57. package/dist/react/components.js +120 -38
  58. package/dist/react/components.js.map +1 -1
  59. package/dist/reporter/html.d.ts +25 -0
  60. package/dist/reporter/html.d.ts.map +1 -0
  61. package/dist/reporter/html.js +396 -0
  62. package/dist/reporter/html.js.map +1 -0
  63. package/dist/reporter/index.d.ts +1 -0
  64. package/dist/reporter/index.d.ts.map +1 -1
  65. package/dist/reporter/index.js +1 -0
  66. package/dist/reporter/index.js.map +1 -1
  67. package/dist/scenarios/__scenarios__/diagnostics-failure.d.ts +58 -0
  68. package/dist/scenarios/__scenarios__/diagnostics-failure.d.ts.map +1 -0
  69. package/dist/scenarios/__scenarios__/diagnostics-failure.js +91 -0
  70. package/dist/scenarios/__scenarios__/diagnostics-failure.js.map +1 -0
  71. package/dist/scenarios/__scenarios__/heartbeat-irregular.d.ts +51 -0
  72. package/dist/scenarios/__scenarios__/heartbeat-irregular.d.ts.map +1 -0
  73. package/dist/scenarios/__scenarios__/heartbeat-irregular.js +81 -0
  74. package/dist/scenarios/__scenarios__/heartbeat-irregular.js.map +1 -0
  75. package/dist/scenarios/__scenarios__/invalid-stop-reason.d.ts +118 -0
  76. package/dist/scenarios/__scenarios__/invalid-stop-reason.d.ts.map +1 -0
  77. package/dist/scenarios/__scenarios__/invalid-stop-reason.js +226 -0
  78. package/dist/scenarios/__scenarios__/invalid-stop-reason.js.map +1 -0
  79. package/dist/scenarios/__scenarios__/meter-anomaly.d.ts +115 -0
  80. package/dist/scenarios/__scenarios__/meter-anomaly.d.ts.map +1 -0
  81. package/dist/scenarios/__scenarios__/meter-anomaly.js +152 -0
  82. package/dist/scenarios/__scenarios__/meter-anomaly.js.map +1 -0
  83. package/dist/scenarios/__scenarios__/meter-value-gap.d.ts +103 -0
  84. package/dist/scenarios/__scenarios__/meter-value-gap.d.ts.map +1 -0
  85. package/dist/scenarios/__scenarios__/meter-value-gap.js +196 -0
  86. package/dist/scenarios/__scenarios__/meter-value-gap.js.map +1 -0
  87. package/dist/scenarios/__scenarios__/short-session.d.ts +111 -0
  88. package/dist/scenarios/__scenarios__/short-session.d.ts.map +1 -0
  89. package/dist/scenarios/__scenarios__/short-session.js +129 -0
  90. package/dist/scenarios/__scenarios__/short-session.js.map +1 -0
  91. package/dist/scenarios/__scenarios__/slow-csms-response.d.ts +52 -0
  92. package/dist/scenarios/__scenarios__/slow-csms-response.d.ts.map +1 -0
  93. package/dist/scenarios/__scenarios__/slow-csms-response.js +61 -0
  94. package/dist/scenarios/__scenarios__/slow-csms-response.js.map +1 -0
  95. package/dist/scenarios/__scenarios__/station-offline.d.ts +6 -0
  96. package/dist/scenarios/__scenarios__/station-offline.d.ts.map +1 -1
  97. package/dist/scenarios/__scenarios__/station-offline.js +10 -0
  98. package/dist/scenarios/__scenarios__/station-offline.js.map +1 -1
  99. package/dist/scenarios/__scenarios__/status-transition-violation.d.ts +52 -0
  100. package/dist/scenarios/__scenarios__/status-transition-violation.d.ts.map +1 -0
  101. package/dist/scenarios/__scenarios__/status-transition-violation.js +112 -0
  102. package/dist/scenarios/__scenarios__/status-transition-violation.js.map +1 -0
  103. package/dist/scenarios/__scenarios__/unexpected-start.d.ts +80 -0
  104. package/dist/scenarios/__scenarios__/unexpected-start.d.ts.map +1 -0
  105. package/dist/scenarios/__scenarios__/unexpected-start.js +124 -0
  106. package/dist/scenarios/__scenarios__/unexpected-start.js.map +1 -0
  107. package/dist/scenarios/__scenarios__/unexpected-stop-reason.d.ts +6 -0
  108. package/dist/scenarios/__scenarios__/unexpected-stop-reason.d.ts.map +1 -1
  109. package/dist/scenarios/__scenarios__/unexpected-stop-reason.js +10 -0
  110. package/dist/scenarios/__scenarios__/unexpected-stop-reason.js.map +1 -1
  111. package/dist/scenarios/__scenarios__/unresponsive-csms.d.ts +53 -0
  112. package/dist/scenarios/__scenarios__/unresponsive-csms.d.ts.map +1 -0
  113. package/dist/scenarios/__scenarios__/unresponsive-csms.js +53 -0
  114. package/dist/scenarios/__scenarios__/unresponsive-csms.js.map +1 -0
  115. package/dist/scenarios/compare.d.ts +39 -0
  116. package/dist/scenarios/compare.d.ts.map +1 -0
  117. package/dist/scenarios/compare.js +77 -0
  118. package/dist/scenarios/compare.js.map +1 -0
  119. package/dist/scenarios/index.d.ts +15 -3
  120. package/dist/scenarios/index.d.ts.map +1 -1
  121. package/dist/scenarios/index.js +45 -4
  122. package/dist/scenarios/index.js.map +1 -1
  123. package/package.json +1 -1
@@ -1,11 +1,28 @@
1
1
  /**
2
2
  * Failure detection — analyzes events and sessions for known failure patterns.
3
3
  *
4
- * Three detection rules in v0.1:
4
+ * 15 detection rules (v0.1 + v0.2 + v0.3):
5
+ *
6
+ * v0.1:
5
7
  * 1. FAILED_AUTHORIZATION — Authorize response with idTagInfo.status = "Invalid"
6
8
  * 2. CONNECTOR_FAULT — StatusNotification with status = "Faulted" during active session
7
- * 3. STATION_OFFLINE_DURING_SESSION — session has StartTransaction but no StopTransaction,
8
- * or connector transitions to Unavailable/Offline during an active transaction
9
+ * 3. STATION_OFFLINE_DURING_SESSION — session has StartTransaction but no StopTransaction
10
+ *
11
+ * v0.2:
12
+ * 4. TIMEOUT_NO_HEARTBEAT — no Heartbeat within expected interval after BootNotification
13
+ * 5. METER_VALUE_GAP — no MeterValues during an active transaction
14
+ * 6. INVALID_STOP_REASON — StopTransaction with unexpected/invalid stop reason
15
+ * 7. UNEXPECTED_START — StartTransaction without preceding BootNotification or Authorize
16
+ * 8. STATUS_TRANSITION_VIOLATION — illegal connector status transition
17
+ * 9. DIAGNOSTICS_FAILURE — DiagnosticsStatusNotification indicating failure
18
+ * 10. FIRMWARE_UPDATE_FAILURE — FirmwareStatusNotification indicating failure
19
+ *
20
+ * v0.3:
21
+ * 11. SUSPICIOUS_SESSION_DURATION — session < 60s or > 24h
22
+ * 12. SLOW_RESPONSE — gap between Call and matching CallResult/CallError > 10s
23
+ * 13. HEARTBEAT_INTERVAL_VIOLATION — heartbeat intervals deviate >50% from expected
24
+ * 14. METER_VALUE_ANOMALY — non-monotonic or negative meter readings
25
+ * 15. UNRESPONSIVE_CSMS — Call with no matching CallResult or CallError
9
26
  *
10
27
  * @see ADR-0003
11
28
  */
@@ -33,11 +50,103 @@ const SUGGESTED_STEPS = {
33
50
  'Check if the station firmware has a known stability issue',
34
51
  'Investigate if maintenance was performed on the station',
35
52
  ],
53
+ TIMEOUT_NO_HEARTBEAT: [
54
+ 'Check the station network connectivity',
55
+ 'Verify the WebSocket connection is stable',
56
+ 'Review the station heartbeat interval configuration',
57
+ 'Check if the station has rebooted or lost power',
58
+ 'Inspect the CSMS for connection acceptance issues',
59
+ ],
60
+ METER_VALUE_GAP: [
61
+ 'Verify the meter is functioning correctly',
62
+ 'Check the meter value reporting interval configuration',
63
+ 'Inspect the OCPP connection stability during the session',
64
+ 'Review station logs for meter communication errors',
65
+ 'Consider hardware replacement if meter is faulty',
66
+ ],
67
+ INVALID_STOP_REASON: [
68
+ 'Review the StopTransaction payload for the stop reason',
69
+ 'Check if the stop reason is within the OCPP 1.6 specification',
70
+ 'Investigate why the station used a non-standard reason',
71
+ 'Review station firmware for stop reason mapping bugs',
72
+ ],
73
+ UNEXPECTED_START: [
74
+ 'Verify the station performed BootNotification before starting a transaction',
75
+ 'Check if authorization was properly completed before StartTransaction',
76
+ 'Review the station startup sequence and timing',
77
+ 'Inspect the CSMS for delayed or missing responses',
78
+ ],
79
+ STATUS_TRANSITION_VIOLATION: [
80
+ 'Review the connector status transition sequence',
81
+ 'Check if the station firmware follows the OCPP status model correctly',
82
+ 'Verify no manual overrides triggered invalid transitions',
83
+ 'Inspect the connector status history for anomalies',
84
+ ],
85
+ DIAGNOSTICS_FAILURE: [
86
+ 'Review the DiagnosticsStatusNotification payload for the specific status',
87
+ 'Check the station diagnostic logs for detailed error information',
88
+ 'Verify the station hardware diagnostics are passing',
89
+ 'Contact hardware vendor if diagnostics indicate hardware failure',
90
+ ],
91
+ FIRMWARE_UPDATE_FAILURE: [
92
+ 'Review the FirmwareStatusNotification payload for the specific status',
93
+ 'Check if the firmware image was corrupted or incomplete',
94
+ 'Verify the station has sufficient storage for the firmware update',
95
+ 'Retry the firmware update after addressing the failure cause',
96
+ 'Contact the firmware provider if the image is defective',
97
+ ],
98
+ SUSPICIOUS_SESSION_DURATION: [
99
+ 'Review the session duration in the trace timeline',
100
+ 'For very short sessions: check if the transaction was aborted or authorization failed mid-session',
101
+ 'For very long sessions: check if the station forgot to send StopTransaction',
102
+ 'Verify the station clock is synchronized (NTP)',
103
+ 'Check if the session spans a maintenance window or firmware update',
104
+ ],
105
+ SLOW_RESPONSE: [
106
+ 'Check the CSMS processing time for the affected message type',
107
+ 'Review system load and database performance on the CSMS',
108
+ 'Verify network latency between station and CSMS',
109
+ 'Check if the CSMS is running a long-running synchronous operation',
110
+ 'Review CSMS logs for the specific message ID',
111
+ ],
112
+ HEARTBEAT_INTERVAL_VIOLATION: [
113
+ 'Verify the heartbeat interval configured on the station matches the BootNotification response',
114
+ 'Check for network instability causing delayed heartbeats',
115
+ 'Review the station clock synchronization (NTP)',
116
+ 'Check if the station firmware has a known heartbeat timing bug',
117
+ 'Inspect the WebSocket connection stability',
118
+ ],
119
+ METER_VALUE_ANOMALY: [
120
+ 'Verify the meter is functioning correctly and is properly calibrated',
121
+ 'Check for meter communication errors or data corruption',
122
+ 'Review the meter value sampling and reporting configuration',
123
+ 'Inspect for potential tampering or hardware malfunction',
124
+ 'Contact the meter vendor if the issue persists',
125
+ ],
126
+ UNRESPONSIVE_CSMS: [
127
+ 'Check if the CSMS was online and accepting connections during the session',
128
+ 'Verify the WebSocket connection was stable at the time of the unanswered Call',
129
+ 'Review CSMS logs for the specific message ID',
130
+ 'Check if the CSMS crashed or restarted during the session',
131
+ 'Inspect the network path between station and CSMS for packet loss',
132
+ ],
36
133
  };
37
134
  const SEVERITY = {
38
135
  FAILED_AUTHORIZATION: 'warning',
39
136
  CONNECTOR_FAULT: 'critical',
40
137
  STATION_OFFLINE_DURING_SESSION: 'critical',
138
+ TIMEOUT_NO_HEARTBEAT: 'warning',
139
+ METER_VALUE_GAP: 'warning',
140
+ INVALID_STOP_REASON: 'info',
141
+ UNEXPECTED_START: 'warning',
142
+ STATUS_TRANSITION_VIOLATION: 'warning',
143
+ DIAGNOSTICS_FAILURE: 'critical',
144
+ FIRMWARE_UPDATE_FAILURE: 'warning',
145
+ SUSPICIOUS_SESSION_DURATION: 'warning',
146
+ SLOW_RESPONSE: 'warning',
147
+ HEARTBEAT_INTERVAL_VIOLATION: 'info',
148
+ METER_VALUE_ANOMALY: 'warning',
149
+ UNRESPONSIVE_CSMS: 'critical',
41
150
  };
42
151
  // ---------------------------------------------------------------------------
43
152
  // Payload extraction helpers
@@ -215,12 +324,521 @@ function detectStationOfflineDuringSession(_events, sessions) {
215
324
  */
216
325
  export function detectFailures(events, sessions) {
217
326
  const failures = [];
218
- // Rule 1: Failed authorization
327
+ // v0.1 rules
219
328
  failures.push(...detectFailedAuthorization(events));
220
- // Rule 2: Connector fault during active session
221
329
  failures.push(...detectConnectorFault(events));
222
- // Rule 3: Station offline during session
223
330
  failures.push(...detectStationOfflineDuringSession(events, sessions));
331
+ // v0.2 rules
332
+ failures.push(...detectTimeoutNoHeartbeat(events));
333
+ failures.push(...detectMeterValueGap(events, sessions));
334
+ failures.push(...detectInvalidStopReason(events));
335
+ failures.push(...detectUnexpectedStart(events));
336
+ failures.push(...detectStatusTransitionViolation(events));
337
+ failures.push(...detectDiagnosticsFailure(events));
338
+ failures.push(...detectFirmwareUpdateFailure(events));
339
+ // v0.3 rules
340
+ failures.push(...detectSuspiciousSessionDuration(events, sessions));
341
+ failures.push(...detectSlowResponse(events));
342
+ failures.push(...detectHeartbeatIntervalViolation(events));
343
+ failures.push(...detectMeterValueAnomaly(events, sessions));
344
+ failures.push(...detectUnresponsiveCsms(events));
345
+ return failures;
346
+ }
347
+ // ---------------------------------------------------------------------------
348
+ // v0.2 detection rules
349
+ // ---------------------------------------------------------------------------
350
+ /** Default heartbeat interval: 60 seconds (OCPP 1.6 default). */
351
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 60_000;
352
+ /** Valid OCPP 1.6 StopTransaction reasons. */
353
+ const VALID_STOP_REASONS = new Set([
354
+ 'EmergencyStop',
355
+ 'EVDisconnected',
356
+ 'HardReset',
357
+ 'Local',
358
+ 'Other',
359
+ 'PowerLoss',
360
+ 'Reboot',
361
+ 'Remote',
362
+ 'SoftReset',
363
+ 'UnlockCommand',
364
+ 'DeAuthorized',
365
+ ]);
366
+ /** Valid connector statuses per OCPP 1.6. */
367
+ const VALID_CONNECTOR_STATUSES = new Set([
368
+ 'Available',
369
+ 'Preparing',
370
+ 'Charging',
371
+ 'SuspendedEVSE',
372
+ 'SuspendedEV',
373
+ 'Finishing',
374
+ 'Reserved',
375
+ 'Unavailable',
376
+ 'Faulted',
377
+ ]);
378
+ /**
379
+ * Rule 4: TIMEOUT_NO_HEARTBEAT
380
+ * Detects when a station sends no Heartbeat within the expected interval
381
+ * after BootNotification. Uses 2x the default interval as the threshold.
382
+ */
383
+ function detectTimeoutNoHeartbeat(events) {
384
+ const failures = [];
385
+ const bootIndex = events.findIndex((e) => e.messageType === 'Call' && e.action === 'BootNotification');
386
+ if (bootIndex === -1)
387
+ return failures;
388
+ const bootEvent = events[bootIndex];
389
+ if (!bootEvent || bootEvent.timestamp === null)
390
+ return failures;
391
+ // Extract heartbeat interval from BootNotification response if present
392
+ let intervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS;
393
+ const bootResponse = events.find((e) => e.messageType === 'CallResult' &&
394
+ e.messageId === bootEvent.messageId &&
395
+ typeof e.payload?.interval === 'number');
396
+ if (bootResponse) {
397
+ const interval = bootResponse.payload.interval;
398
+ intervalMs = interval * 1000;
399
+ }
400
+ // Look for any Heartbeat within 2x the expected interval after BootNotification
401
+ const threshold = bootEvent.timestamp + intervalMs * 2;
402
+ // Only flag if the trace has events beyond the heartbeat threshold
403
+ // (if the trace ends before the first heartbeat is due, we can't know
404
+ // if the station would have sent one)
405
+ const hasEventsBeyondThreshold = events.some((e) => e.timestamp !== null && e.timestamp > threshold);
406
+ if (!hasEventsBeyondThreshold)
407
+ return failures;
408
+ const hasHeartbeat = events.some((e) => e.messageType === 'Call' &&
409
+ e.action === 'Heartbeat' &&
410
+ e.timestamp !== null &&
411
+ e.timestamp <= threshold);
412
+ if (!hasHeartbeat) {
413
+ failures.push({
414
+ code: 'TIMEOUT_NO_HEARTBEAT',
415
+ description: `No Heartbeat received within ${(intervalMs * 2) / 1000}s of BootNotification (expected every ${intervalMs / 1000}s)`,
416
+ severity: SEVERITY.TIMEOUT_NO_HEARTBEAT,
417
+ eventIds: [bootEvent.id],
418
+ suggestedSteps: SUGGESTED_STEPS.TIMEOUT_NO_HEARTBEAT,
419
+ });
420
+ }
421
+ return failures;
422
+ }
423
+ /**
424
+ * Rule 5: METER_VALUE_GAP
425
+ * Detects active transactions (StartTransaction to StopTransaction) where
426
+ * no MeterValuesRequest was sent.
427
+ */
428
+ function detectMeterValueGap(_events, sessions) {
429
+ const failures = [];
430
+ for (const session of sessions) {
431
+ if (session.transactionId === null)
432
+ continue;
433
+ const hasStart = session.events.some((e) => e.messageType === 'Call' && e.action === 'StartTransaction');
434
+ const hasStop = session.events.some((e) => e.messageType === 'Call' && e.action === 'StopTransaction');
435
+ if (!hasStart || !hasStop)
436
+ continue;
437
+ const hasMeterValues = session.events.some((e) => e.messageType === 'Call' && e.action === 'MeterValues');
438
+ if (!hasMeterValues) {
439
+ const startEvent = session.events.find((e) => e.messageType === 'Call' && e.action === 'StartTransaction');
440
+ failures.push({
441
+ code: 'METER_VALUE_GAP',
442
+ description: `Session ${session.sessionId} (transaction ${session.transactionId}) has StartTransaction and StopTransaction but no MeterValues — metering data is missing`,
443
+ severity: SEVERITY.METER_VALUE_GAP,
444
+ eventIds: startEvent ? [startEvent.id] : [],
445
+ suggestedSteps: SUGGESTED_STEPS.METER_VALUE_GAP,
446
+ });
447
+ }
448
+ }
449
+ return failures;
450
+ }
451
+ /**
452
+ * Rule 6: INVALID_STOP_REASON
453
+ * Detects StopTransaction with a stop reason not in the OCPP 1.6 specification.
454
+ */
455
+ function detectInvalidStopReason(events) {
456
+ const failures = [];
457
+ for (const event of events) {
458
+ if (event.messageType !== 'Call' || event.action !== 'StopTransaction')
459
+ continue;
460
+ const payload = event.payload;
461
+ const reason = payload?.reason;
462
+ if (typeof reason === 'string' && !VALID_STOP_REASONS.has(reason)) {
463
+ failures.push({
464
+ code: 'INVALID_STOP_REASON',
465
+ description: `StopTransaction has invalid stop reason "${reason}" — not a valid OCPP 1.6 reason code (messageId: ${event.messageId})`,
466
+ severity: SEVERITY.INVALID_STOP_REASON,
467
+ eventIds: [event.id],
468
+ suggestedSteps: SUGGESTED_STEPS.INVALID_STOP_REASON,
469
+ });
470
+ }
471
+ }
472
+ return failures;
473
+ }
474
+ /**
475
+ * Rule 7: UNEXPECTED_START
476
+ * Detects StartTransaction without a preceding BootNotification or Authorize.
477
+ */
478
+ function detectUnexpectedStart(events) {
479
+ const failures = [];
480
+ for (let i = 0; i < events.length; i++) {
481
+ const event = events[i];
482
+ if (!event)
483
+ continue;
484
+ if (event.messageType !== 'Call' || event.action !== 'StartTransaction')
485
+ continue;
486
+ // Check for BootNotification or Authorize before this StartTransaction
487
+ const hasBoot = events
488
+ .slice(0, i)
489
+ .some((e) => e.messageType === 'Call' && e.action === 'BootNotification');
490
+ const hasAuthorize = events
491
+ .slice(0, i)
492
+ .some((e) => e.messageType === 'Call' && e.action === 'Authorize');
493
+ if (!hasBoot && !hasAuthorize) {
494
+ failures.push({
495
+ code: 'UNEXPECTED_START',
496
+ description: `StartTransaction at event ${event.id} without preceding BootNotification or Authorize — transaction started without proper initialization (messageId: ${event.messageId})`,
497
+ severity: SEVERITY.UNEXPECTED_START,
498
+ eventIds: [event.id],
499
+ suggestedSteps: SUGGESTED_STEPS.UNEXPECTED_START,
500
+ });
501
+ }
502
+ }
503
+ return failures;
504
+ }
505
+ /**
506
+ * Rule 8: STATUS_TRANSITION_VIOLATION
507
+ * Detects illegal connector status transitions.
508
+ * Valid transitions are based on the OCPP 1.6 connector state model.
509
+ */
510
+ const VALID_TRANSITIONS = {
511
+ Available: new Set(['Preparing', 'Charging', 'Reserved', 'Unavailable', 'Faulted']),
512
+ Preparing: new Set(['Charging', 'Available', 'SuspendedEVSE', 'Faulted', 'Unavailable']),
513
+ Charging: new Set(['SuspendedEVSE', 'SuspendedEV', 'Finishing', 'Available', 'Faulted']),
514
+ SuspendedEVSE: new Set(['Charging', 'Finishing', 'Available', 'Faulted']),
515
+ SuspendedEV: new Set(['Charging', 'Finishing', 'Available', 'Faulted']),
516
+ Finishing: new Set(['Available', 'Reserved', 'Faulted']),
517
+ Reserved: new Set(['Available', 'Unavailable', 'Faulted']),
518
+ Unavailable: new Set(['Available', 'Faulted']),
519
+ Faulted: new Set(['Unavailable', 'Available']),
520
+ };
521
+ function detectStatusTransitionViolation(events) {
522
+ const failures = [];
523
+ // Collect all StatusNotification statuses in order
524
+ const statusEvents = events.filter((e) => e.messageType === 'Call' && e.action === 'StatusNotification');
525
+ let prevStatus = null;
526
+ let prevEvent = null;
527
+ for (const event of statusEvents) {
528
+ const payload = event.payload;
529
+ const status = payload?.status;
530
+ if (typeof status !== 'string' || !VALID_CONNECTOR_STATUSES.has(status))
531
+ continue;
532
+ if (prevStatus !== null && prevEvent !== null) {
533
+ const allowed = VALID_TRANSITIONS[prevStatus];
534
+ if (allowed && !allowed.has(status)) {
535
+ failures.push({
536
+ code: 'STATUS_TRANSITION_VIOLATION',
537
+ description: `Connector status transition from "${prevStatus}" to "${status}" is not a valid OCPP 1.6 transition (messageId: ${event.messageId})`,
538
+ severity: SEVERITY.STATUS_TRANSITION_VIOLATION,
539
+ eventIds: [prevEvent.id, event.id],
540
+ suggestedSteps: SUGGESTED_STEPS.STATUS_TRANSITION_VIOLATION,
541
+ });
542
+ }
543
+ }
544
+ prevStatus = status;
545
+ prevEvent = event;
546
+ }
547
+ return failures;
548
+ }
549
+ /**
550
+ * Rule 9: DIAGNOSTICS_FAILURE
551
+ * Detects DiagnosticsStatusNotification with a failure status.
552
+ */
553
+ const DIAGNOSTICS_FAILURE_STATUSES = new Set([
554
+ 'Idle',
555
+ 'Uploaded',
556
+ 'UploadFailed',
557
+ 'DiagnosisFailed',
558
+ 'NotImplemented',
559
+ ]);
560
+ function detectDiagnosticsFailure(events) {
561
+ const failures = [];
562
+ for (const event of events) {
563
+ if (event.messageType !== 'Call' || event.action !== 'DiagnosticsStatusNotification')
564
+ continue;
565
+ const payload = event.payload;
566
+ const status = payload?.status;
567
+ if (typeof status === 'string' && DIAGNOSTICS_FAILURE_STATUSES.has(status)) {
568
+ // Only report actual failures, not successful diagnostics
569
+ if (status === 'UploadFailed' || status === 'DiagnosisFailed') {
570
+ failures.push({
571
+ code: 'DIAGNOSTICS_FAILURE',
572
+ description: `DiagnosticsStatusNotification reported failure status "${status}" (messageId: ${event.messageId})`,
573
+ severity: SEVERITY.DIAGNOSTICS_FAILURE,
574
+ eventIds: [event.id],
575
+ suggestedSteps: SUGGESTED_STEPS.DIAGNOSTICS_FAILURE,
576
+ });
577
+ }
578
+ }
579
+ }
580
+ return failures;
581
+ }
582
+ /**
583
+ * Rule 10: FIRMWARE_UPDATE_FAILURE
584
+ * Detects FirmwareStatusNotification indicating a firmware update failure.
585
+ */
586
+ const FIRMWARE_FAILURE_STATUSES = new Set([
587
+ 'DownloadFailed',
588
+ 'DownloadPaused',
589
+ 'InstallFailed',
590
+ 'InstallRebootingFailed',
591
+ ]);
592
+ function detectFirmwareUpdateFailure(events) {
593
+ const failures = [];
594
+ for (const event of events) {
595
+ if (event.messageType !== 'Call' || event.action !== 'FirmwareStatusNotification')
596
+ continue;
597
+ const payload = event.payload;
598
+ const status = payload?.status;
599
+ if (typeof status === 'string' && FIRMWARE_FAILURE_STATUSES.has(status)) {
600
+ failures.push({
601
+ code: 'FIRMWARE_UPDATE_FAILURE',
602
+ description: `FirmwareStatusNotification reported failure status "${status}" (messageId: ${event.messageId})`,
603
+ severity: SEVERITY.FIRMWARE_UPDATE_FAILURE,
604
+ eventIds: [event.id],
605
+ suggestedSteps: SUGGESTED_STEPS.FIRMWARE_UPDATE_FAILURE,
606
+ });
607
+ }
608
+ }
609
+ return failures;
610
+ }
611
+ // ---------------------------------------------------------------------------
612
+ // v0.3 detection rules
613
+ // ---------------------------------------------------------------------------
614
+ /** Minimum session duration to not be suspicious: 60 seconds. */
615
+ const MIN_SESSION_DURATION_MS = 60_000;
616
+ /** Maximum session duration to not be suspicious: 24 hours. */
617
+ const MAX_SESSION_DURATION_MS = 24 * 60 * 60 * 1000;
618
+ /** Threshold for slow response: 10 seconds. */
619
+ const SLOW_RESPONSE_THRESHOLD_MS = 10_000;
620
+ /** Deviation threshold for heartbeat interval violation: 50%. */
621
+ const HEARTBEAT_DEVIATION_THRESHOLD = 0.5;
622
+ /**
623
+ * Rule 11: SUSPICIOUS_SESSION_DURATION
624
+ * Detects sessions that are suspiciously short (< 60s) or long (> 24h).
625
+ */
626
+ function detectSuspiciousSessionDuration(_events, sessions) {
627
+ const failures = [];
628
+ for (const session of sessions) {
629
+ if (session.transactionId === null)
630
+ continue;
631
+ if (session.startTime === null || session.endTime === null)
632
+ continue;
633
+ const durationMs = session.endTime - session.startTime;
634
+ if (durationMs < MIN_SESSION_DURATION_MS) {
635
+ failures.push({
636
+ code: 'SUSPICIOUS_SESSION_DURATION',
637
+ description: `Session ${session.sessionId} (transaction ${session.transactionId}) lasted only ${durationMs}ms — suspiciously short session may indicate aborted start or authorization failure`,
638
+ severity: SEVERITY.SUSPICIOUS_SESSION_DURATION,
639
+ eventIds: session.events
640
+ .filter((e) => e.messageType === 'Call' &&
641
+ (e.action === 'StartTransaction' || e.action === 'StopTransaction'))
642
+ .map((e) => e.id),
643
+ suggestedSteps: SUGGESTED_STEPS.SUSPICIOUS_SESSION_DURATION,
644
+ });
645
+ }
646
+ else if (durationMs > MAX_SESSION_DURATION_MS) {
647
+ failures.push({
648
+ code: 'SUSPICIOUS_SESSION_DURATION',
649
+ description: `Session ${session.sessionId} (transaction ${session.transactionId}) lasted ${Math.round(durationMs / (60 * 60 * 1000))} hours — suspiciously long session may indicate forgotten charging or missing StopTransaction`,
650
+ severity: SEVERITY.SUSPICIOUS_SESSION_DURATION,
651
+ eventIds: session.events
652
+ .filter((e) => e.messageType === 'Call' &&
653
+ (e.action === 'StartTransaction' || e.action === 'StopTransaction'))
654
+ .map((e) => e.id),
655
+ suggestedSteps: SUGGESTED_STEPS.SUSPICIOUS_SESSION_DURATION,
656
+ });
657
+ }
658
+ }
659
+ return failures;
660
+ }
661
+ /**
662
+ * Rule 12: SLOW_RESPONSE
663
+ * Detects when the gap between a Call and its matching CallResult/CallError
664
+ * exceeds the threshold (default 10s).
665
+ */
666
+ function detectSlowResponse(events) {
667
+ const failures = [];
668
+ // Build a map of messageId → CallResult/CallError timestamps
669
+ const responseMap = new Map();
670
+ for (const event of events) {
671
+ if ((event.messageType === 'CallResult' || event.messageType === 'CallError') &&
672
+ event.timestamp !== null) {
673
+ responseMap.set(event.messageId, event.timestamp);
674
+ }
675
+ }
676
+ for (const call of events) {
677
+ if (call.messageType !== 'Call' || call.timestamp === null)
678
+ continue;
679
+ const responseTime = responseMap.get(call.messageId);
680
+ if (responseTime === undefined)
681
+ continue; // No matching response — handled by UNRESPONSIVE_CSMS
682
+ const gapMs = responseTime - call.timestamp;
683
+ if (gapMs > SLOW_RESPONSE_THRESHOLD_MS) {
684
+ failures.push({
685
+ code: 'SLOW_RESPONSE',
686
+ description: `Response to ${call.action} (messageId: ${call.messageId}) took ${Math.round(gapMs / 1000)}s — exceeds ${SLOW_RESPONSE_THRESHOLD_MS / 1000}s threshold`,
687
+ severity: SEVERITY.SLOW_RESPONSE,
688
+ eventIds: [call.id],
689
+ suggestedSteps: SUGGESTED_STEPS.SLOW_RESPONSE,
690
+ });
691
+ }
692
+ }
693
+ return failures;
694
+ }
695
+ /**
696
+ * Rule 13: HEARTBEAT_INTERVAL_VIOLATION
697
+ * Detects when heartbeat intervals deviate >50% from the expected interval
698
+ * (from BootNotification response, or default 60s).
699
+ */
700
+ function detectHeartbeatIntervalViolation(events) {
701
+ const failures = [];
702
+ // Extract expected heartbeat interval from BootNotification response
703
+ let expectedIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS;
704
+ const bootIndex = events.findIndex((e) => e.messageType === 'Call' && e.action === 'BootNotification');
705
+ if (bootIndex !== -1) {
706
+ const bootEvent = events[bootIndex];
707
+ if (bootEvent) {
708
+ const bootResponse = events.find((e) => e.messageType === 'CallResult' &&
709
+ e.messageId === bootEvent.messageId &&
710
+ typeof e.payload?.interval === 'number');
711
+ if (bootResponse) {
712
+ const interval = bootResponse.payload.interval;
713
+ expectedIntervalMs = interval * 1000;
714
+ }
715
+ }
716
+ }
717
+ // Collect heartbeat timestamps in order
718
+ const heartbeatTimestamps = events
719
+ .filter((e) => e.messageType === 'Call' && e.action === 'Heartbeat' && e.timestamp !== null)
720
+ .map((e) => ({ id: e.id, timestamp: e.timestamp }));
721
+ if (heartbeatTimestamps.length < 2)
722
+ return failures;
723
+ for (let i = 1; i < heartbeatTimestamps.length; i++) {
724
+ const prev = heartbeatTimestamps[i - 1];
725
+ const curr = heartbeatTimestamps[i];
726
+ if (!prev || !curr)
727
+ continue;
728
+ const actualGap = curr.timestamp - prev.timestamp;
729
+ const deviation = Math.abs(actualGap - expectedIntervalMs) / expectedIntervalMs;
730
+ if (deviation > HEARTBEAT_DEVIATION_THRESHOLD) {
731
+ failures.push({
732
+ code: 'HEARTBEAT_INTERVAL_VIOLATION',
733
+ description: `Heartbeat interval deviation: ${Math.round(actualGap / 1000)}s between heartbeats, expected ~${Math.round(expectedIntervalMs / 1000)}s (${Math.round(deviation * 100)}% deviation)`,
734
+ severity: SEVERITY.HEARTBEAT_INTERVAL_VIOLATION,
735
+ eventIds: [prev.id, curr.id],
736
+ suggestedSteps: SUGGESTED_STEPS.HEARTBEAT_INTERVAL_VIOLATION,
737
+ });
738
+ }
739
+ }
740
+ return failures;
741
+ }
742
+ /**
743
+ * Rule 14: METER_VALUE_ANOMALY
744
+ * Detects non-monotonic (decreasing) or negative meter readings during
745
+ * an active transaction.
746
+ */
747
+ function detectMeterValueAnomaly(_events, sessions) {
748
+ const failures = [];
749
+ for (const session of sessions) {
750
+ if (session.transactionId === null)
751
+ continue;
752
+ // Collect meter values from MeterValues calls in order
753
+ const meterEvents = session.events.filter((e) => e.messageType === 'Call' && e.action === 'MeterValues');
754
+ if (meterEvents.length === 0)
755
+ continue;
756
+ // Extract numeric meter values from the payload
757
+ // OCPP 1.6 MeterValues: { connectorId, transactionId, meterValue: [{ timestamp, sampledValue: [{ value, ... }] }] }
758
+ const readings = [];
759
+ for (const event of meterEvents) {
760
+ const payload = event.payload;
761
+ const meterValues = payload?.meterValue;
762
+ if (!Array.isArray(meterValues))
763
+ continue;
764
+ for (const mv of meterValues) {
765
+ const sampledValues = mv?.sampledValue;
766
+ if (!Array.isArray(sampledValues))
767
+ continue;
768
+ for (const sv of sampledValues) {
769
+ const rawValue = sv?.value;
770
+ if (typeof rawValue === 'string') {
771
+ const numValue = Number.parseFloat(rawValue);
772
+ if (!Number.isNaN(numValue)) {
773
+ readings.push({ eventId: event.id, value: numValue });
774
+ }
775
+ }
776
+ else if (typeof rawValue === 'number') {
777
+ readings.push({ eventId: event.id, value: rawValue });
778
+ }
779
+ }
780
+ }
781
+ }
782
+ if (readings.length === 0)
783
+ continue;
784
+ // Check for negative values
785
+ for (const reading of readings) {
786
+ if (reading.value < 0) {
787
+ failures.push({
788
+ code: 'METER_VALUE_ANOMALY',
789
+ description: `Negative meter value detected: ${reading.value} in session ${session.sessionId} (transaction ${session.transactionId})`,
790
+ severity: SEVERITY.METER_VALUE_ANOMALY,
791
+ eventIds: [reading.eventId],
792
+ suggestedSteps: SUGGESTED_STEPS.METER_VALUE_ANOMALY,
793
+ });
794
+ }
795
+ }
796
+ // Check for non-monotonic (decreasing) values
797
+ for (let i = 1; i < readings.length; i++) {
798
+ const prev = readings[i - 1];
799
+ const curr = readings[i];
800
+ if (!prev || !curr)
801
+ continue;
802
+ if (curr.value < prev.value) {
803
+ failures.push({
804
+ code: 'METER_VALUE_ANOMALY',
805
+ description: `Non-monotonic meter reading: value decreased from ${prev.value} to ${curr.value} in session ${session.sessionId} (transaction ${session.transactionId})`,
806
+ severity: SEVERITY.METER_VALUE_ANOMALY,
807
+ eventIds: [prev.eventId, curr.eventId],
808
+ suggestedSteps: SUGGESTED_STEPS.METER_VALUE_ANOMALY,
809
+ });
810
+ }
811
+ }
812
+ }
813
+ return failures;
814
+ }
815
+ /**
816
+ * Rule 15: UNRESPONSIVE_CSMS
817
+ * Detects Call messages that have no matching CallResult or CallError
818
+ * (the CSMS never responded).
819
+ */
820
+ function detectUnresponsiveCsms(events) {
821
+ const failures = [];
822
+ // Build a set of messageIds that have responses
823
+ const respondedMessageIds = new Set();
824
+ for (const event of events) {
825
+ if (event.messageType === 'CallResult' || event.messageType === 'CallError') {
826
+ respondedMessageIds.add(event.messageId);
827
+ }
828
+ }
829
+ for (const call of events) {
830
+ if (call.messageType !== 'Call')
831
+ continue;
832
+ if (!respondedMessageIds.has(call.messageId)) {
833
+ failures.push({
834
+ code: 'UNRESPONSIVE_CSMS',
835
+ description: `No response received for ${call.action} Call (messageId: ${call.messageId}) — CSMS did not respond with CallResult or CallError`,
836
+ severity: SEVERITY.UNRESPONSIVE_CSMS,
837
+ eventIds: [call.id],
838
+ suggestedSteps: SUGGESTED_STEPS.UNRESPONSIVE_CSMS,
839
+ });
840
+ }
841
+ }
224
842
  return failures;
225
843
  }
226
844
  //# sourceMappingURL=detection.js.map