Skip to content

Commit b9a037a

Browse files
committed
feat(core): v0.2 failure detection rules — 7 new rules
Add 7 new failure detection rules to packages/toolkit/src/core/detection.ts: - TIMEOUT_NO_HEARTBEAT: no Heartbeat within 2x expected interval after BootNotification - METER_VALUE_GAP: no MeterValues during a completed transaction - INVALID_STOP_REASON: StopTransaction with non-standard reason code - UNEXPECTED_START: StartTransaction without preceding BootNotification or Authorize - STATUS_TRANSITION_VIOLATION: illegal connector status transition per OCPP 1.6 - DIAGNOSTICS_FAILURE: DiagnosticsStatusNotification with failure status - FIRMWARE_UPDATE_FAILURE: FirmwareStatusNotification with failure status Update FailureCode type with 7 new codes. Add suggested steps and severity for each. Add 18 unit tests (2-3 per rule). Fix existing fixtures: add Heartbeat messages to normal-session, connector-fault, station-offline, and unexpected-stop-reason fixtures so new TIMEOUT_NO_HEARTBEAT rule does not false-positive. Fix connector-fault stop reason from 'Faulted' (invalid) to 'Other'. Closes #59
1 parent aa9b2c3 commit b9a037a

8 files changed

Lines changed: 758 additions & 13 deletions

File tree

‎packages/toolkit/src/core/__fixtures__/connector-fault.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,16 @@ export default {
148148
direction: 'CSMS_TO_CS',
149149
message: [3, 'msg-006', {}],
150150
},
151+
{
152+
timestamp: '2024-01-15T12:07:30.000Z',
153+
direction: 'CS_TO_CSMS',
154+
message: [2, 'msg-hb-1', 'Heartbeat', {}],
155+
},
156+
{
157+
timestamp: '2024-01-15T12:07:30.500Z',
158+
direction: 'CSMS_TO_CS',
159+
message: [3, 'msg-hb-1', { currentTime: '2024-01-15T12:07:30.500Z' }],
160+
},
151161
{
152162
timestamp: '2024-01-15T12:15:00.000Z',
153163
direction: 'CS_TO_CSMS',
@@ -210,7 +220,7 @@ export default {
210220
idTag: 'SYNTHETIC-TAG-002',
211221
meterStop: 3500,
212222
timestamp: '2024-01-15T12:18:05.000Z',
213-
reason: 'Faulted',
223+
reason: 'Other',
214224
},
215225
],
216226
},

‎packages/toolkit/src/core/__fixtures__/normal-session.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,16 @@ export default {
149149
direction: 'CSMS_TO_CS',
150150
message: [3, 'msg-006', {}],
151151
},
152+
{
153+
timestamp: '2024-01-15T10:07:30.000Z',
154+
direction: 'CS_TO_CSMS',
155+
message: [2, 'msg-hb-1', 'Heartbeat', {}],
156+
},
157+
{
158+
timestamp: '2024-01-15T10:07:30.500Z',
159+
direction: 'CSMS_TO_CS',
160+
message: [3, 'msg-hb-1', { currentTime: '2024-01-15T10:07:30.500Z' }],
161+
},
152162
{
153163
timestamp: '2024-01-15T10:15:00.000Z',
154164
direction: 'CS_TO_CSMS',

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

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,4 +441,307 @@ describe('detectFailures', () => {
441441
expect(failures.some((f) => f.code === 'CONNECTOR_FAULT')).toBe(true);
442442
});
443443
});
444+
445+
// -------------------------------------------------------------------------
446+
// v0.2 detection rules
447+
// -------------------------------------------------------------------------
448+
449+
describe('TIMEOUT_NO_HEARTBEAT', () => {
450+
it('detects missing heartbeat when trace spans beyond threshold', () => {
451+
const events = [
452+
makeEvent('e1', 'm1', 'Call', 'BootNotification', {}, 0),
453+
makeEvent(
454+
'e2',
455+
'm1',
456+
'CallResult',
457+
null,
458+
{ interval: 300, status: 'Accepted' },
459+
500,
460+
'CSMS_TO_CS',
461+
),
462+
// No heartbeat, but events span beyond 600s (2x300)
463+
makeEvent('e3', 'm2', 'Call', 'StatusNotification', { status: 'Available' }, 700_000),
464+
];
465+
const sessions = buildSessionTimeline(events);
466+
const failures = detectFailures(events, sessions);
467+
expect(failures.some((f) => f.code === 'TIMEOUT_NO_HEARTBEAT')).toBe(true);
468+
});
469+
470+
it('does not flag when heartbeat is present', () => {
471+
const events = [
472+
makeEvent('e1', 'm1', 'Call', 'BootNotification', {}, 0),
473+
makeEvent(
474+
'e2',
475+
'm1',
476+
'CallResult',
477+
null,
478+
{ interval: 300, status: 'Accepted' },
479+
500,
480+
'CSMS_TO_CS',
481+
),
482+
makeEvent('e3', 'm2', 'Call', 'Heartbeat', {}, 100_000),
483+
makeEvent('e4', 'm2', 'CallResult', null, {}, 100_500, 'CSMS_TO_CS'),
484+
makeEvent('e5', 'm3', 'Call', 'StatusNotification', { status: 'Available' }, 700_000),
485+
];
486+
const sessions = buildSessionTimeline(events);
487+
const failures = detectFailures(events, sessions);
488+
expect(failures.some((f) => f.code === 'TIMEOUT_NO_HEARTBEAT')).toBe(false);
489+
});
490+
491+
it('does not flag when trace ends before first heartbeat is due', () => {
492+
const events = [
493+
makeEvent('e1', 'm1', 'Call', 'BootNotification', {}, 0),
494+
makeEvent(
495+
'e2',
496+
'm1',
497+
'CallResult',
498+
null,
499+
{ interval: 300, status: 'Accepted' },
500+
500,
501+
'CSMS_TO_CS',
502+
),
503+
// Trace ends at 100s — before 600s threshold
504+
makeEvent('e3', 'm2', 'Call', 'StatusNotification', { status: 'Available' }, 100_000),
505+
];
506+
const sessions = buildSessionTimeline(events);
507+
const failures = detectFailures(events, sessions);
508+
expect(failures.some((f) => f.code === 'TIMEOUT_NO_HEARTBEAT')).toBe(false);
509+
});
510+
511+
it('does not flag when no BootNotification', () => {
512+
const events = [makeEvent('e1', 'm1', 'Call', 'Heartbeat', {}, 0)];
513+
const sessions = buildSessionTimeline(events);
514+
const failures = detectFailures(events, sessions);
515+
expect(failures.some((f) => f.code === 'TIMEOUT_NO_HEARTBEAT')).toBe(false);
516+
});
517+
});
518+
519+
describe('METER_VALUE_GAP', () => {
520+
it('detects missing MeterValues in completed transaction', () => {
521+
const events = [
522+
makeEvent('e1', 'm1', 'Call', 'StartTransaction', { connectorId: 1, transactionId: 1 }, 0),
523+
makeEvent('e2', 'm1', 'CallResult', null, { transactionId: 1 }, 500, 'CSMS_TO_CS'),
524+
makeEvent(
525+
'e3',
526+
'm2',
527+
'Call',
528+
'StopTransaction',
529+
{ transactionId: 1, reason: 'EVDisconnected' },
530+
10_000,
531+
),
532+
makeEvent('e4', 'm2', 'CallResult', null, {}, 10_500, 'CSMS_TO_CS'),
533+
];
534+
const sessions = buildSessionTimeline(events);
535+
const failures = detectFailures(events, sessions);
536+
expect(failures.some((f) => f.code === 'METER_VALUE_GAP')).toBe(true);
537+
});
538+
539+
it('does not flag when MeterValues are present', () => {
540+
const events = [
541+
makeEvent('e1', 'm1', 'Call', 'StartTransaction', { connectorId: 1, transactionId: 1 }, 0),
542+
makeEvent('e2', 'm1', 'CallResult', null, { transactionId: 1 }, 500, 'CSMS_TO_CS'),
543+
makeEvent('e3', 'm2', 'Call', 'MeterValues', { connectorId: 1, transactionId: 1 }, 5_000),
544+
makeEvent('e4', 'm2', 'CallResult', null, {}, 5_500, 'CSMS_TO_CS'),
545+
makeEvent(
546+
'e5',
547+
'm3',
548+
'Call',
549+
'StopTransaction',
550+
{ transactionId: 1, reason: 'EVDisconnected' },
551+
10_000,
552+
),
553+
makeEvent('e6', 'm3', 'CallResult', null, {}, 10_500, 'CSMS_TO_CS'),
554+
];
555+
const sessions = buildSessionTimeline(events);
556+
const failures = detectFailures(events, sessions);
557+
expect(failures.some((f) => f.code === 'METER_VALUE_GAP')).toBe(false);
558+
});
559+
});
560+
561+
describe('INVALID_STOP_REASON', () => {
562+
it('detects invalid stop reason', () => {
563+
const events = [
564+
makeEvent(
565+
'e1',
566+
'm1',
567+
'Call',
568+
'StopTransaction',
569+
{ transactionId: 1, reason: 'BadReason' },
570+
0,
571+
),
572+
];
573+
const sessions = buildSessionTimeline(events);
574+
const failures = detectFailures(events, sessions);
575+
expect(failures.some((f) => f.code === 'INVALID_STOP_REASON')).toBe(true);
576+
});
577+
578+
it('does not flag valid stop reason', () => {
579+
const events = [
580+
makeEvent(
581+
'e1',
582+
'm1',
583+
'Call',
584+
'StopTransaction',
585+
{ transactionId: 1, reason: 'EVDisconnected' },
586+
0,
587+
),
588+
];
589+
const sessions = buildSessionTimeline(events);
590+
const failures = detectFailures(events, sessions);
591+
expect(failures.some((f) => f.code === 'INVALID_STOP_REASON')).toBe(false);
592+
});
593+
});
594+
595+
describe('UNEXPECTED_START', () => {
596+
it('detects StartTransaction without BootNotification or Authorize', () => {
597+
const events = [
598+
makeEvent('e1', 'm1', 'Call', 'StartTransaction', { connectorId: 1 }, 0),
599+
makeEvent('e2', 'm1', 'CallResult', null, { transactionId: 1 }, 500, 'CSMS_TO_CS'),
600+
];
601+
const sessions = buildSessionTimeline(events);
602+
const failures = detectFailures(events, sessions);
603+
expect(failures.some((f) => f.code === 'UNEXPECTED_START')).toBe(true);
604+
});
605+
606+
it('does not flag when BootNotification precedes StartTransaction', () => {
607+
const events = [
608+
makeEvent('e1', 'm1', 'Call', 'BootNotification', {}, 0),
609+
makeEvent('e2', 'm1', 'CallResult', null, { status: 'Accepted' }, 500, 'CSMS_TO_CS'),
610+
makeEvent('e3', 'm2', 'Call', 'StartTransaction', { connectorId: 1 }, 1000),
611+
makeEvent('e4', 'm2', 'CallResult', null, { transactionId: 1 }, 1500, 'CSMS_TO_CS'),
612+
];
613+
const sessions = buildSessionTimeline(events);
614+
const failures = detectFailures(events, sessions);
615+
expect(failures.some((f) => f.code === 'UNEXPECTED_START')).toBe(false);
616+
});
617+
});
618+
619+
describe('STATUS_TRANSITION_VIOLATION', () => {
620+
it('detects illegal transition from Available to Finishing', () => {
621+
const events = [
622+
makeEvent(
623+
'e1',
624+
'm1',
625+
'Call',
626+
'StatusNotification',
627+
{ connectorId: 1, status: 'Available' },
628+
0,
629+
),
630+
makeEvent(
631+
'e2',
632+
'm2',
633+
'Call',
634+
'StatusNotification',
635+
{ connectorId: 1, status: 'Finishing' },
636+
1000,
637+
),
638+
];
639+
const sessions = buildSessionTimeline(events);
640+
const failures = detectFailures(events, sessions);
641+
expect(failures.some((f) => f.code === 'STATUS_TRANSITION_VIOLATION')).toBe(true);
642+
});
643+
644+
it('does not flag valid transition from Available to Preparing', () => {
645+
const events = [
646+
makeEvent(
647+
'e1',
648+
'm1',
649+
'Call',
650+
'StatusNotification',
651+
{ connectorId: 1, status: 'Available' },
652+
0,
653+
),
654+
makeEvent(
655+
'e2',
656+
'm2',
657+
'Call',
658+
'StatusNotification',
659+
{ connectorId: 1, status: 'Preparing' },
660+
1000,
661+
),
662+
];
663+
const sessions = buildSessionTimeline(events);
664+
const failures = detectFailures(events, sessions);
665+
expect(failures.some((f) => f.code === 'STATUS_TRANSITION_VIOLATION')).toBe(false);
666+
});
667+
});
668+
669+
describe('DIAGNOSTICS_FAILURE', () => {
670+
it('detects UploadFailed diagnostics status', () => {
671+
const events = [
672+
makeEvent(
673+
'e1',
674+
'm1',
675+
'Call',
676+
'DiagnosticsStatusNotification',
677+
{ status: 'UploadFailed' },
678+
0,
679+
),
680+
];
681+
const sessions = buildSessionTimeline(events);
682+
const failures = detectFailures(events, sessions);
683+
expect(failures.some((f) => f.code === 'DIAGNOSTICS_FAILURE')).toBe(true);
684+
});
685+
686+
it('detects DiagnosisFailed diagnostics status', () => {
687+
const events = [
688+
makeEvent(
689+
'e1',
690+
'm1',
691+
'Call',
692+
'DiagnosticsStatusNotification',
693+
{ status: 'DiagnosisFailed' },
694+
0,
695+
),
696+
];
697+
const sessions = buildSessionTimeline(events);
698+
const failures = detectFailures(events, sessions);
699+
expect(failures.some((f) => f.code === 'DIAGNOSTICS_FAILURE')).toBe(true);
700+
});
701+
702+
it('does not flag Uploaded diagnostics status', () => {
703+
const events = [
704+
makeEvent('e1', 'm1', 'Call', 'DiagnosticsStatusNotification', { status: 'Uploaded' }, 0),
705+
];
706+
const sessions = buildSessionTimeline(events);
707+
const failures = detectFailures(events, sessions);
708+
expect(failures.some((f) => f.code === 'DIAGNOSTICS_FAILURE')).toBe(false);
709+
});
710+
});
711+
712+
describe('FIRMWARE_UPDATE_FAILURE', () => {
713+
it('detects DownloadFailed firmware status', () => {
714+
const events = [
715+
makeEvent(
716+
'e1',
717+
'm1',
718+
'Call',
719+
'FirmwareStatusNotification',
720+
{ status: 'DownloadFailed' },
721+
0,
722+
),
723+
];
724+
const sessions = buildSessionTimeline(events);
725+
const failures = detectFailures(events, sessions);
726+
expect(failures.some((f) => f.code === 'FIRMWARE_UPDATE_FAILURE')).toBe(true);
727+
});
728+
729+
it('detects InstallFailed firmware status', () => {
730+
const events = [
731+
makeEvent('e1', 'm1', 'Call', 'FirmwareStatusNotification', { status: 'InstallFailed' }, 0),
732+
];
733+
const sessions = buildSessionTimeline(events);
734+
const failures = detectFailures(events, sessions);
735+
expect(failures.some((f) => f.code === 'FIRMWARE_UPDATE_FAILURE')).toBe(true);
736+
});
737+
738+
it('does not flag Downloaded firmware status', () => {
739+
const events = [
740+
makeEvent('e1', 'm1', 'Call', 'FirmwareStatusNotification', { status: 'Downloaded' }, 0),
741+
];
742+
const sessions = buildSessionTimeline(events);
743+
const failures = detectFailures(events, sessions);
744+
expect(failures.some((f) => f.code === 'FIRMWARE_UPDATE_FAILURE')).toBe(false);
745+
});
746+
});
444747
});

0 commit comments

Comments
 (0)