Skip to content

Commit 2c4d518

Browse files
feat(core): detect repeated boot notifications (#114)
* feat(core): detect repeated boot notifications * docs: fix CURRENT_STATE.md heading for PR #114 Use PR-based heading instead of premature v1.0.0 milestone section. --------- Co-authored-by: sepehr-safari <safari.sepehr@gmail.com>
1 parent 94d1e33 commit 2c4d518

6 files changed

Lines changed: 224 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ocpp-debugkit/toolkit': patch
3+
---
4+
5+
Add repeated BootNotification failure detection for stations that send multiple boot calls within five minutes.

CURRENT_STATE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,11 @@ examples, and contributor onboarding.
214214
- ✅ Git tag `v0.3.0` + GitHub release `v0.3.0` created
215215
- ✅ v0.3.0 milestone closed
216216

217+
### Repeated BootNotification Detection (PR #114)
218+
219+
-`REPEATED_BOOT_NOTIFICATION` — flags 2+ BootNotification calls within
220+
five minutes. Added as the 16th detection rule (Issue #105).
221+
217222
## What's Next
218223

219224
1. **v1.0.0 — Stable FOSS Ecosystem** — API stabilization, 20+ scenarios, docs

packages/toolkit/src/core/detection.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,4 +1513,141 @@ describe('detectFailures', () => {
15131513
expect(failures.some((f) => f.code === 'UNRESPONSIVE_CSMS')).toBe(false);
15141514
});
15151515
});
1516+
1517+
describe('REPEATED_BOOT_NOTIFICATION', () => {
1518+
it('detects multiple BootNotification calls within 5 minutes', () => {
1519+
const events = [
1520+
makeEvent(
1521+
'e1',
1522+
'm1',
1523+
'Call',
1524+
'BootNotification',
1525+
{ chargePointSerialNumber: 'CS-SYNTHETIC-001' },
1526+
0,
1527+
),
1528+
makeEvent(
1529+
'e2',
1530+
'm1',
1531+
'CallResult',
1532+
null,
1533+
{ status: 'Accepted', interval: 60 },
1534+
500,
1535+
'CSMS_TO_CS',
1536+
),
1537+
makeEvent(
1538+
'e3',
1539+
'm2',
1540+
'Call',
1541+
'BootNotification',
1542+
{ chargePointSerialNumber: 'CS-SYNTHETIC-001' },
1543+
4 * 60 * 1000,
1544+
),
1545+
makeEvent(
1546+
'e4',
1547+
'm2',
1548+
'CallResult',
1549+
null,
1550+
{ status: 'Accepted', interval: 60 },
1551+
4 * 60 * 1000 + 500,
1552+
'CSMS_TO_CS',
1553+
),
1554+
];
1555+
const sessions = buildSessionTimeline(events);
1556+
const failures = detectFailures(events, sessions);
1557+
const repeatedBootFailures = failures.filter(
1558+
(failure) => failure.code === 'REPEATED_BOOT_NOTIFICATION',
1559+
);
1560+
1561+
expect(repeatedBootFailures).toHaveLength(1);
1562+
expect(repeatedBootFailures[0]?.severity).toBe('warning');
1563+
expect(repeatedBootFailures[0]?.eventIds).toEqual(['e1', 'e3']);
1564+
expect(repeatedBootFailures[0]?.suggestedSteps.length).toBeGreaterThan(0);
1565+
});
1566+
1567+
it('does not flag BootNotification calls more than 5 minutes apart', () => {
1568+
const events = [
1569+
makeEvent(
1570+
'e1',
1571+
'm1',
1572+
'Call',
1573+
'BootNotification',
1574+
{ chargePointSerialNumber: 'CS-SYNTHETIC-001' },
1575+
0,
1576+
),
1577+
makeEvent(
1578+
'e2',
1579+
'm1',
1580+
'CallResult',
1581+
null,
1582+
{ status: 'Accepted', interval: 60 },
1583+
500,
1584+
'CSMS_TO_CS',
1585+
),
1586+
makeEvent(
1587+
'e3',
1588+
'm2',
1589+
'Call',
1590+
'BootNotification',
1591+
{ chargePointSerialNumber: 'CS-SYNTHETIC-001' },
1592+
5 * 60 * 1000 + 1,
1593+
),
1594+
makeEvent(
1595+
'e4',
1596+
'm2',
1597+
'CallResult',
1598+
null,
1599+
{ status: 'Accepted', interval: 60 },
1600+
5 * 60 * 1000 + 501,
1601+
'CSMS_TO_CS',
1602+
),
1603+
];
1604+
const sessions = buildSessionTimeline(events);
1605+
const failures = detectFailures(events, sessions);
1606+
1607+
expect(failures.some((failure) => failure.code === 'REPEATED_BOOT_NOTIFICATION')).toBe(false);
1608+
});
1609+
1610+
it('does not flag repeated BootNotification calls without timestamps', () => {
1611+
const events = [
1612+
makeEvent(
1613+
'e1',
1614+
'm1',
1615+
'Call',
1616+
'BootNotification',
1617+
{ chargePointSerialNumber: 'CS-SYNTHETIC-001' },
1618+
null,
1619+
),
1620+
makeEvent(
1621+
'e2',
1622+
'm1',
1623+
'CallResult',
1624+
null,
1625+
{ status: 'Accepted', interval: 60 },
1626+
null,
1627+
'CSMS_TO_CS',
1628+
),
1629+
makeEvent(
1630+
'e3',
1631+
'm2',
1632+
'Call',
1633+
'BootNotification',
1634+
{ chargePointSerialNumber: 'CS-SYNTHETIC-001' },
1635+
null,
1636+
),
1637+
makeEvent(
1638+
'e4',
1639+
'm2',
1640+
'CallResult',
1641+
null,
1642+
{ status: 'Accepted', interval: 60 },
1643+
null,
1644+
'CSMS_TO_CS',
1645+
),
1646+
];
1647+
const sessions = buildSessionTimeline(events);
1648+
const failures = detectFailures(events, sessions);
1649+
1650+
expect(failures.some((failure) => failure.code === 'REPEATED_BOOT_NOTIFICATION')).toBe(false);
1651+
});
1652+
});
15161653
});

packages/toolkit/src/core/detection.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Failure detection — analyzes events and sessions for known failure patterns.
33
*
4-
* 15 detection rules (v0.1 + v0.2 + v0.3):
4+
* 16 detection rules (v0.1 + v0.2 + v0.3):
55
*
66
* v0.1:
77
* 1. FAILED_AUTHORIZATION — Authorize response with idTagInfo.status = "Invalid"
@@ -23,6 +23,7 @@
2323
* 13. HEARTBEAT_INTERVAL_VIOLATION — heartbeat intervals deviate >50% from expected
2424
* 14. METER_VALUE_ANOMALY — non-monotonic or negative meter readings
2525
* 15. UNRESPONSIVE_CSMS — Call with no matching CallResult or CallError
26+
* 16. REPEATED_BOOT_NOTIFICATION — 2+ BootNotification calls within 5 minutes
2627
*
2728
* @see ADR-0003
2829
*/
@@ -134,6 +135,13 @@ const SUGGESTED_STEPS: Record<FailureCode, string[]> = {
134135
'Check if the CSMS crashed or restarted during the session',
135136
'Inspect the network path between station and CSMS for packet loss',
136137
],
138+
REPEATED_BOOT_NOTIFICATION: [
139+
'Check whether the station rebooted unexpectedly',
140+
'Review station power and network stability during the boot window',
141+
'Inspect station firmware logs for watchdog resets or startup failures',
142+
'Verify the CSMS accepts the BootNotification and returns a valid interval',
143+
'Contact the station vendor if repeated boots persist',
144+
],
137145
};
138146

139147
const SEVERITY: Record<FailureCode, FailureSeverity> = {
@@ -152,6 +160,7 @@ const SEVERITY: Record<FailureCode, FailureSeverity> = {
152160
HEARTBEAT_INTERVAL_VIOLATION: 'info',
153161
METER_VALUE_ANOMALY: 'warning',
154162
UNRESPONSIVE_CSMS: 'critical',
163+
REPEATED_BOOT_NOTIFICATION: 'warning',
155164
};
156165

157166
// ---------------------------------------------------------------------------
@@ -380,6 +389,7 @@ export function detectFailures(events: Event[], sessions: Session[]): Failure[]
380389
failures.push(...detectHeartbeatIntervalViolation(events));
381390
failures.push(...detectMeterValueAnomaly(events, sessions));
382391
failures.push(...detectUnresponsiveCsms(events));
392+
failures.push(...detectRepeatedBootNotification(events));
383393

384394
return failures;
385395
}
@@ -725,6 +735,9 @@ const SLOW_RESPONSE_THRESHOLD_MS = 10_000;
725735
/** Deviation threshold for heartbeat interval violation: 50%. */
726736
const HEARTBEAT_DEVIATION_THRESHOLD = 0.5;
727737

738+
/** Time window for repeated BootNotification calls: 5 minutes. */
739+
const REPEATED_BOOT_NOTIFICATION_WINDOW_MS = 5 * 60 * 1000;
740+
728741
/**
729742
* Rule 11: SUSPICIOUS_SESSION_DURATION
730743
* Detects sessions that are suspiciously short (< 60s) or long (> 24h).
@@ -988,3 +1001,63 @@ function detectUnresponsiveCsms(events: Event[]): Failure[] {
9881001

9891002
return failures;
9901003
}
1004+
1005+
/**
1006+
* Rule 16: REPEATED_BOOT_NOTIFICATION
1007+
* Detects when a station sends 2+ BootNotification Calls within 5 minutes.
1008+
*/
1009+
function detectRepeatedBootNotification(events: Event[]): Failure[] {
1010+
const failures: Failure[] = [];
1011+
1012+
const bootEvents = events
1013+
.filter(
1014+
(event) =>
1015+
event.messageType === 'Call' &&
1016+
event.action === 'BootNotification' &&
1017+
event.timestamp !== null,
1018+
)
1019+
.sort((a, b) => (a.timestamp as number) - (b.timestamp as number));
1020+
1021+
for (let i = 0; i < bootEvents.length; i++) {
1022+
const firstBoot = bootEvents[i];
1023+
if (!firstBoot || firstBoot.timestamp === null) continue;
1024+
1025+
const repeatedBoots = [firstBoot];
1026+
let j = i + 1;
1027+
1028+
while (j < bootEvents.length) {
1029+
const nextBoot = bootEvents[j];
1030+
if (!nextBoot || nextBoot.timestamp === null) {
1031+
j++;
1032+
continue;
1033+
}
1034+
1035+
if (nextBoot.timestamp - firstBoot.timestamp > REPEATED_BOOT_NOTIFICATION_WINDOW_MS) {
1036+
break;
1037+
}
1038+
1039+
repeatedBoots.push(nextBoot);
1040+
j++;
1041+
}
1042+
1043+
if (repeatedBoots.length >= 2) {
1044+
const windowSeconds = Math.round(
1045+
((repeatedBoots[repeatedBoots.length - 1]?.timestamp ?? firstBoot.timestamp) -
1046+
firstBoot.timestamp) /
1047+
1000,
1048+
);
1049+
1050+
failures.push({
1051+
code: 'REPEATED_BOOT_NOTIFICATION',
1052+
description: `${repeatedBoots.length} BootNotification calls detected within ${windowSeconds}s — station may be rebooting repeatedly or failing startup`,
1053+
severity: SEVERITY.REPEATED_BOOT_NOTIFICATION,
1054+
eventIds: repeatedBoots.map((event) => event.id),
1055+
suggestedSteps: SUGGESTED_STEPS.REPEATED_BOOT_NOTIFICATION,
1056+
});
1057+
1058+
i = j - 1;
1059+
}
1060+
}
1061+
1062+
return failures;
1063+
}

packages/toolkit/src/core/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ export type FailureCode =
167167
| 'SLOW_RESPONSE'
168168
| 'HEARTBEAT_INTERVAL_VIOLATION'
169169
| 'METER_VALUE_ANOMALY'
170-
| 'UNRESPONSIVE_CSMS';
170+
| 'UNRESPONSIVE_CSMS'
171+
| 'REPEATED_BOOT_NOTIFICATION';
171172

172173
/**
173174
* A detected failure in a trace.

packages/toolkit/src/scenarios/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ describe('expectedFailures alignment with detection rules', () => {
103103
'HEARTBEAT_INTERVAL_VIOLATION',
104104
'METER_VALUE_ANOMALY',
105105
'UNRESPONSIVE_CSMS',
106+
'REPEATED_BOOT_NOTIFICATION',
106107
]);
107108

108109
it('all expectedFailures reference valid failure codes', () => {

0 commit comments

Comments
 (0)