Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/repeated-boot-notification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ocpp-debugkit/toolkit': patch
---

Add repeated BootNotification failure detection for stations that send multiple boot calls within five minutes.
5 changes: 5 additions & 0 deletions CURRENT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@ examples, and contributor onboarding.
- ✅ Git tag `v0.3.0` + GitHub release `v0.3.0` created
- ✅ v0.3.0 milestone closed

### Repeated BootNotification Detection (PR #114)

- ✅ `REPEATED_BOOT_NOTIFICATION` — flags 2+ BootNotification calls within
five minutes. Added as the 16th detection rule (Issue #105).

## What's Next

1. **v1.0.0 — Stable FOSS Ecosystem** — API stabilization, 20+ scenarios, docs
Expand Down
137 changes: 137 additions & 0 deletions packages/toolkit/src/core/detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1513,4 +1513,141 @@ describe('detectFailures', () => {
expect(failures.some((f) => f.code === 'UNRESPONSIVE_CSMS')).toBe(false);
});
});

describe('REPEATED_BOOT_NOTIFICATION', () => {
it('detects multiple BootNotification calls within 5 minutes', () => {
const events = [
makeEvent(
'e1',
'm1',
'Call',
'BootNotification',
{ chargePointSerialNumber: 'CS-SYNTHETIC-001' },
0,
),
makeEvent(
'e2',
'm1',
'CallResult',
null,
{ status: 'Accepted', interval: 60 },
500,
'CSMS_TO_CS',
),
makeEvent(
'e3',
'm2',
'Call',
'BootNotification',
{ chargePointSerialNumber: 'CS-SYNTHETIC-001' },
4 * 60 * 1000,
),
makeEvent(
'e4',
'm2',
'CallResult',
null,
{ status: 'Accepted', interval: 60 },
4 * 60 * 1000 + 500,
'CSMS_TO_CS',
),
];
const sessions = buildSessionTimeline(events);
const failures = detectFailures(events, sessions);
const repeatedBootFailures = failures.filter(
(failure) => failure.code === 'REPEATED_BOOT_NOTIFICATION',
);

expect(repeatedBootFailures).toHaveLength(1);
expect(repeatedBootFailures[0]?.severity).toBe('warning');
expect(repeatedBootFailures[0]?.eventIds).toEqual(['e1', 'e3']);
expect(repeatedBootFailures[0]?.suggestedSteps.length).toBeGreaterThan(0);
});

it('does not flag BootNotification calls more than 5 minutes apart', () => {
const events = [
makeEvent(
'e1',
'm1',
'Call',
'BootNotification',
{ chargePointSerialNumber: 'CS-SYNTHETIC-001' },
0,
),
makeEvent(
'e2',
'm1',
'CallResult',
null,
{ status: 'Accepted', interval: 60 },
500,
'CSMS_TO_CS',
),
makeEvent(
'e3',
'm2',
'Call',
'BootNotification',
{ chargePointSerialNumber: 'CS-SYNTHETIC-001' },
5 * 60 * 1000 + 1,
),
makeEvent(
'e4',
'm2',
'CallResult',
null,
{ status: 'Accepted', interval: 60 },
5 * 60 * 1000 + 501,
'CSMS_TO_CS',
),
];
const sessions = buildSessionTimeline(events);
const failures = detectFailures(events, sessions);

expect(failures.some((failure) => failure.code === 'REPEATED_BOOT_NOTIFICATION')).toBe(false);
});

it('does not flag repeated BootNotification calls without timestamps', () => {
const events = [
makeEvent(
'e1',
'm1',
'Call',
'BootNotification',
{ chargePointSerialNumber: 'CS-SYNTHETIC-001' },
null,
),
makeEvent(
'e2',
'm1',
'CallResult',
null,
{ status: 'Accepted', interval: 60 },
null,
'CSMS_TO_CS',
),
makeEvent(
'e3',
'm2',
'Call',
'BootNotification',
{ chargePointSerialNumber: 'CS-SYNTHETIC-001' },
null,
),
makeEvent(
'e4',
'm2',
'CallResult',
null,
{ status: 'Accepted', interval: 60 },
null,
'CSMS_TO_CS',
),
];
const sessions = buildSessionTimeline(events);
const failures = detectFailures(events, sessions);

expect(failures.some((failure) => failure.code === 'REPEATED_BOOT_NOTIFICATION')).toBe(false);
});
});
});
75 changes: 74 additions & 1 deletion packages/toolkit/src/core/detection.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Failure detection — analyzes events and sessions for known failure patterns.
*
* 15 detection rules (v0.1 + v0.2 + v0.3):
* 16 detection rules (v0.1 + v0.2 + v0.3):
*
* v0.1:
* 1. FAILED_AUTHORIZATION — Authorize response with idTagInfo.status = "Invalid"
Expand All @@ -23,6 +23,7 @@
* 13. HEARTBEAT_INTERVAL_VIOLATION — heartbeat intervals deviate >50% from expected
* 14. METER_VALUE_ANOMALY — non-monotonic or negative meter readings
* 15. UNRESPONSIVE_CSMS — Call with no matching CallResult or CallError
* 16. REPEATED_BOOT_NOTIFICATION — 2+ BootNotification calls within 5 minutes
*
* @see ADR-0003
*/
Expand Down Expand Up @@ -134,6 +135,13 @@ const SUGGESTED_STEPS: Record<FailureCode, string[]> = {
'Check if the CSMS crashed or restarted during the session',
'Inspect the network path between station and CSMS for packet loss',
],
REPEATED_BOOT_NOTIFICATION: [
'Check whether the station rebooted unexpectedly',
'Review station power and network stability during the boot window',
'Inspect station firmware logs for watchdog resets or startup failures',
'Verify the CSMS accepts the BootNotification and returns a valid interval',
'Contact the station vendor if repeated boots persist',
],
};

const SEVERITY: Record<FailureCode, FailureSeverity> = {
Expand All @@ -152,6 +160,7 @@ const SEVERITY: Record<FailureCode, FailureSeverity> = {
HEARTBEAT_INTERVAL_VIOLATION: 'info',
METER_VALUE_ANOMALY: 'warning',
UNRESPONSIVE_CSMS: 'critical',
REPEATED_BOOT_NOTIFICATION: 'warning',
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -380,6 +389,7 @@ export function detectFailures(events: Event[], sessions: Session[]): Failure[]
failures.push(...detectHeartbeatIntervalViolation(events));
failures.push(...detectMeterValueAnomaly(events, sessions));
failures.push(...detectUnresponsiveCsms(events));
failures.push(...detectRepeatedBootNotification(events));

return failures;
}
Expand Down Expand Up @@ -725,6 +735,9 @@ const SLOW_RESPONSE_THRESHOLD_MS = 10_000;
/** Deviation threshold for heartbeat interval violation: 50%. */
const HEARTBEAT_DEVIATION_THRESHOLD = 0.5;

/** Time window for repeated BootNotification calls: 5 minutes. */
const REPEATED_BOOT_NOTIFICATION_WINDOW_MS = 5 * 60 * 1000;

/**
* Rule 11: SUSPICIOUS_SESSION_DURATION
* Detects sessions that are suspiciously short (< 60s) or long (> 24h).
Expand Down Expand Up @@ -988,3 +1001,63 @@ function detectUnresponsiveCsms(events: Event[]): Failure[] {

return failures;
}

/**
* Rule 16: REPEATED_BOOT_NOTIFICATION
* Detects when a station sends 2+ BootNotification Calls within 5 minutes.
*/
function detectRepeatedBootNotification(events: Event[]): Failure[] {
const failures: Failure[] = [];

const bootEvents = events
.filter(
(event) =>
event.messageType === 'Call' &&
event.action === 'BootNotification' &&
event.timestamp !== null,
)
.sort((a, b) => (a.timestamp as number) - (b.timestamp as number));

for (let i = 0; i < bootEvents.length; i++) {
const firstBoot = bootEvents[i];
if (!firstBoot || firstBoot.timestamp === null) continue;

const repeatedBoots = [firstBoot];
let j = i + 1;

while (j < bootEvents.length) {
const nextBoot = bootEvents[j];
if (!nextBoot || nextBoot.timestamp === null) {
j++;
continue;
}

if (nextBoot.timestamp - firstBoot.timestamp > REPEATED_BOOT_NOTIFICATION_WINDOW_MS) {
break;
}

repeatedBoots.push(nextBoot);
j++;
}

if (repeatedBoots.length >= 2) {
const windowSeconds = Math.round(
((repeatedBoots[repeatedBoots.length - 1]?.timestamp ?? firstBoot.timestamp) -
firstBoot.timestamp) /
1000,
);

failures.push({
code: 'REPEATED_BOOT_NOTIFICATION',
description: `${repeatedBoots.length} BootNotification calls detected within ${windowSeconds}s — station may be rebooting repeatedly or failing startup`,
severity: SEVERITY.REPEATED_BOOT_NOTIFICATION,
eventIds: repeatedBoots.map((event) => event.id),
suggestedSteps: SUGGESTED_STEPS.REPEATED_BOOT_NOTIFICATION,
});

i = j - 1;
}
}

return failures;
}
3 changes: 2 additions & 1 deletion packages/toolkit/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ export type FailureCode =
| 'SLOW_RESPONSE'
| 'HEARTBEAT_INTERVAL_VIOLATION'
| 'METER_VALUE_ANOMALY'
| 'UNRESPONSIVE_CSMS';
| 'UNRESPONSIVE_CSMS'
| 'REPEATED_BOOT_NOTIFICATION';

/**
* A detected failure in a trace.
Expand Down
1 change: 1 addition & 0 deletions packages/toolkit/src/scenarios/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ describe('expectedFailures alignment with detection rules', () => {
'HEARTBEAT_INTERVAL_VIOLATION',
'METER_VALUE_ANOMALY',
'UNRESPONSIVE_CSMS',
'REPEATED_BOOT_NOTIFICATION',
]);

it('all expectedFailures reference valid failure codes', () => {
Expand Down
Loading