diff --git a/.changeset/repeated-boot-notification.md b/.changeset/repeated-boot-notification.md new file mode 100644 index 0000000..ccde306 --- /dev/null +++ b/.changeset/repeated-boot-notification.md @@ -0,0 +1,5 @@ +--- +'@ocpp-debugkit/toolkit': patch +--- + +Add repeated BootNotification failure detection for stations that send multiple boot calls within five minutes. diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 5ca082a..c95e41e 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -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 diff --git a/packages/toolkit/src/core/detection.test.ts b/packages/toolkit/src/core/detection.test.ts index 7ee623e..9149c00 100644 --- a/packages/toolkit/src/core/detection.test.ts +++ b/packages/toolkit/src/core/detection.test.ts @@ -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); + }); + }); }); diff --git a/packages/toolkit/src/core/detection.ts b/packages/toolkit/src/core/detection.ts index f971f1f..62b9f7a 100644 --- a/packages/toolkit/src/core/detection.ts +++ b/packages/toolkit/src/core/detection.ts @@ -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" @@ -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 */ @@ -134,6 +135,13 @@ const SUGGESTED_STEPS: Record = { '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 = { @@ -152,6 +160,7 @@ const SEVERITY: Record = { HEARTBEAT_INTERVAL_VIOLATION: 'info', METER_VALUE_ANOMALY: 'warning', UNRESPONSIVE_CSMS: 'critical', + REPEATED_BOOT_NOTIFICATION: 'warning', }; // --------------------------------------------------------------------------- @@ -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; } @@ -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). @@ -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; +} diff --git a/packages/toolkit/src/core/types.ts b/packages/toolkit/src/core/types.ts index f504ee5..569bc69 100644 --- a/packages/toolkit/src/core/types.ts +++ b/packages/toolkit/src/core/types.ts @@ -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. diff --git a/packages/toolkit/src/scenarios/index.test.ts b/packages/toolkit/src/scenarios/index.test.ts index 28b1493..92ffafd 100644 --- a/packages/toolkit/src/scenarios/index.test.ts +++ b/packages/toolkit/src/scenarios/index.test.ts @@ -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', () => {