Skip to content

Commit 036a0fa

Browse files
committed
fix(core): transcribe the section 4.9 status transition table
VALID_TRANSITIONS disagreed with the transition table in OCPP 1.6 edition 2, section 4.9, in both directions: it flagged 22 transitions the table permits, and permitted 2 the table does not list. The false positives sat mostly in the recovery rows. The table's I1-I8 row lets a connector return from Faulted to any pre-fault state, and the H row lets one come back from Unavailable into any operative state, but the matrix allowed recovery only to Available. So a station that faulted mid-session and resumed charging, or that took a scheduled availability change during a session (C8, D8, E8, F8), produced a warning that said the firmware was wrong when the firmware was right. Available to SuspendedEV/SuspendedEVSE (A4, A5), Preparing to Finishing (B6, added by the 1.6 errata), Finishing to Preparing (F2) and Reserved to Preparing (G2) were flagged the same way. In the other direction, Preparing to Unavailable and Finishing to Reserved have no cell in the table and are now reported. The matrix is now the table cell by cell, ordered as the table's columns are and annotated with the spec's own cell labels, so the next reader can check a row against section 4.9 instead of against intuition. A test transcribes the table independently and walks all 72 ordered pairs in both directions, which is the guard that was missing when this drifted. The status-transition-violation scenario (Available to Finishing) is still detected, so the scenario corpus and the conformance contract are unchanged. Two adjacent questions are left alone so this stays a straight transcription: a repeated identical status is still a violation, since the table has no diagonal, though a TriggerMessage-driven StatusNotification legitimately repeats the current status; and connectorId 0 is still checked against the full table rather than the narrower set section 4.9 allows it.
1 parent bd2c489 commit 036a0fa

4 files changed

Lines changed: 254 additions & 11 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+
Transcribe the `STATUS_TRANSITION_VIOLATION` matrix from the OCPP 1.6 status transition table (#155, edition 2 section 4.9). The matrix disagreed with the table in both directions: it flagged 22 transitions the table permits and permitted 2 it does not list. The false positives were concentrated in the recovery rows, where the table allows a connector to return from `Faulted` to any pre-fault state and from `Unavailable` straight into an operative state, so any station that faulted mid-session and resumed charging, or that took a scheduled availability change during a session (`Charging -> Unavailable` and its siblings), produced a spurious warning. `Preparing -> Unavailable` and `Finishing -> Reserved` are absent from the table and are now flagged. The rule's matrix is now the table cell by cell, with the spec's own cell labels alongside it, and a test transcribes the table independently so the two have to agree.

‎CURRENT_STATE.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,28 @@ these fixes introduce is required across detection.
6262
per-connector violations still detected (status-transition-violation scenario
6363
and conformance contract unchanged); 3 regression tests added
6464

65+
### STATUS_TRANSITION_VIOLATION table transcription (Issue #155)
66+
67+
- ✅ `VALID_TRANSITIONS` is now the OCPP 1.6 edition 2 section 4.9 transition
68+
table cell by cell, 53 permitted transitions, with the spec's own cell labels
69+
in comments so a row can be checked against the table
70+
- ✅ Removes 22 false positives, most of them in the recovery rows: the table
71+
allows a connector to return from `Faulted` to any pre-fault state (`I1`-`I8`)
72+
and from `Unavailable` into any operative state, and allows a scheduled
73+
availability change during a session (`C8`, `D8`, `E8`, `F8`)
74+
- ✅ Adds the 2 transitions the table omits but the rule permitted:
75+
`Preparing -> Unavailable` and `Finishing -> Reserved`
76+
- ✅ A test transcribes the table independently and checks all 72 ordered pairs,
77+
so the matrix cannot drift from the spec again without a named failure
78+
- ✅ status-transition-violation scenario (`Available -> Finishing`) still
79+
detected, so the scenario corpus and the conformance contract are unchanged
80+
- Left deliberately unchanged, each worth its own issue: a repeated identical
81+
status is still a violation (the table has no diagonal, but a
82+
`TriggerMessage`-driven `StatusNotification` legitimately repeats), and
83+
connectorId 0 is not checked against its narrower applicable set
84+
- Studio carries an independent copy of this matrix and needs the same
85+
transcription to stay equivalent under `contract-v1`
86+
6587
### GitHub Infrastructure
6688

6789
- ✅ GitHub milestones created (M0, M0.5, v0.1.0, v0.2.0, v0.3.0, v1.0.0)

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

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,141 @@ describe('detectFailures', () => {
704704
expect(violations).toHaveLength(1);
705705
expect(violations[0]?.eventIds).toEqual(['e1', 'e3']);
706706
});
707+
708+
// The transition table of OCPP 1.6 edition 2, section 4.9, transcribed
709+
// independently of the rule's own matrix so the two have to agree. Written
710+
// out as the spec table reads, row by row, cell labels in the comments.
711+
const SPEC_TRANSITION_TABLE: Record<string, string[]> = {
712+
// A2, A3, A4, A5, A7, A8, A9
713+
Available: [
714+
'Preparing',
715+
'Charging',
716+
'SuspendedEV',
717+
'SuspendedEVSE',
718+
'Reserved',
719+
'Unavailable',
720+
'Faulted',
721+
],
722+
// B1, B3, B4, B5, B6, B9
723+
Preparing: ['Available', 'Charging', 'SuspendedEV', 'SuspendedEVSE', 'Finishing', 'Faulted'],
724+
// C1, C4, C5, C6, C8, C9
725+
Charging: [
726+
'Available',
727+
'SuspendedEV',
728+
'SuspendedEVSE',
729+
'Finishing',
730+
'Unavailable',
731+
'Faulted',
732+
],
733+
// D1, D3, D5, D6, D8, D9
734+
SuspendedEV: [
735+
'Available',
736+
'Charging',
737+
'SuspendedEVSE',
738+
'Finishing',
739+
'Unavailable',
740+
'Faulted',
741+
],
742+
// E1, E3, E4, E6, E8, E9
743+
SuspendedEVSE: [
744+
'Available',
745+
'Charging',
746+
'SuspendedEV',
747+
'Finishing',
748+
'Unavailable',
749+
'Faulted',
750+
],
751+
// F1, F2, F8, F9
752+
Finishing: ['Available', 'Preparing', 'Unavailable', 'Faulted'],
753+
// G1, G2, G8, G9
754+
Reserved: ['Available', 'Preparing', 'Unavailable', 'Faulted'],
755+
// H1, H2, H3, H4, H5, H9
756+
Unavailable: [
757+
'Available',
758+
'Preparing',
759+
'Charging',
760+
'SuspendedEV',
761+
'SuspendedEVSE',
762+
'Faulted',
763+
],
764+
// I1 through I8
765+
Faulted: [
766+
'Available',
767+
'Preparing',
768+
'Charging',
769+
'SuspendedEV',
770+
'SuspendedEVSE',
771+
'Finishing',
772+
'Reserved',
773+
'Unavailable',
774+
],
775+
};
776+
777+
const CONNECTOR_STATUSES = Object.keys(SPEC_TRANSITION_TABLE);
778+
779+
const isFlagged = (from: string, to: string): boolean => {
780+
const events = [statusEvent('e1', 1, from, 0), statusEvent('e2', 1, to, 1000)];
781+
return detectFailures(events, buildSessionTimeline(events)).some(
782+
(f) => f.code === 'STATUS_TRANSITION_VIOLATION',
783+
);
784+
};
785+
786+
it('permits every transition the section 4.9 table lists', () => {
787+
const wronglyFlagged: string[] = [];
788+
for (const [from, targets] of Object.entries(SPEC_TRANSITION_TABLE)) {
789+
for (const to of targets) {
790+
if (isFlagged(from, to)) wronglyFlagged.push(`${from} -> ${to}`);
791+
}
792+
}
793+
expect(wronglyFlagged).toEqual([]);
794+
});
795+
796+
it('flags every transition the section 4.9 table omits', () => {
797+
const wronglyPermitted: string[] = [];
798+
for (const from of CONNECTOR_STATUSES) {
799+
for (const to of CONNECTOR_STATUSES) {
800+
if (from === to) continue; // the diagonal is covered separately
801+
if (SPEC_TRANSITION_TABLE[from]?.includes(to)) continue;
802+
if (!isFlagged(from, to)) wronglyPermitted.push(`${from} -> ${to}`);
803+
}
804+
}
805+
expect(wronglyPermitted).toEqual([]);
806+
});
807+
808+
// Spot checks for the rows the old matrix got most wrong, kept as named
809+
// tests so a regression names itself instead of showing up as a list diff.
810+
it('permits recovery from Faulted into any pre-fault state (row I)', () => {
811+
for (const to of SPEC_TRANSITION_TABLE.Faulted ?? []) {
812+
expect(isFlagged('Faulted', to)).toBe(false);
813+
}
814+
});
815+
816+
it('permits resuming an operative state from Unavailable (row H)', () => {
817+
for (const to of SPEC_TRANSITION_TABLE.Unavailable ?? []) {
818+
expect(isFlagged('Unavailable', to)).toBe(false);
819+
}
820+
});
821+
822+
it('permits a scheduled availability change during a session (C8, D8, E8, F8)', () => {
823+
for (const from of ['Charging', 'SuspendedEV', 'SuspendedEVSE', 'Finishing']) {
824+
expect(isFlagged(from, 'Unavailable')).toBe(false);
825+
}
826+
});
827+
828+
it('permits Preparing to Finishing, the B6 cell added by the 1.6 errata', () => {
829+
expect(isFlagged('Preparing', 'Finishing')).toBe(false);
830+
});
831+
832+
it('flags Preparing to Unavailable and Finishing to Reserved, absent from the table', () => {
833+
expect(isFlagged('Preparing', 'Unavailable')).toBe(true);
834+
expect(isFlagged('Finishing', 'Reserved')).toBe(true);
835+
});
836+
837+
it('flags a repeated identical status, since the table has no diagonal', () => {
838+
for (const status of CONNECTOR_STATUSES) {
839+
expect(isFlagged(status, status)).toBe(true);
840+
}
841+
});
707842
});
708843

709844
describe('DIAGNOSTICS_FAILURE', () => {

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

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -595,19 +595,100 @@ function detectUnexpectedStart(events: Event[]): Failure[] {
595595

596596
/**
597597
* Rule 8: STATUS_TRANSITION_VIOLATION
598-
* Detects illegal connector status transitions.
599-
* Valid transitions are based on the OCPP 1.6 connector state model.
598+
* Detects connector status transitions the OCPP 1.6 status model does not list.
599+
*
600+
* This is a cell-by-cell transcription of the transition table in OCPP 1.6
601+
* edition 2, section 4.9: 53 permitted transitions across the nine
602+
* `ChargePointStatus` values. Each entry is ordered the way the table's columns
603+
* are (Available, Preparing, Charging, SuspendedEV, SuspendedEVSE, Finishing,
604+
* Reserved, Unavailable, Faulted) and carries the table's own cell labels, so a
605+
* reader can check a row against the spec rather than against intuition. Keep it
606+
* that way: this matrix drifted from the table once already.
607+
*
608+
* Two properties of the table worth knowing before reading a violation:
609+
*
610+
* - The `Faulted` row (`I1`-`I8`) permits recovery to any pre-fault state, and
611+
* the `Unavailable` row permits resuming directly into an operative state, so
612+
* a station that faults mid-session and resumes is conformant.
613+
* - The table has no diagonal, so a repeated identical status counts as a
614+
* violation here. A charge point answering a `TriggerMessage` for
615+
* `StatusNotification` legitimately repeats its current status.
616+
*
617+
* The table applies to connectorId > 0. Section 4.9 limits connectorId 0 to
618+
* Available, Unavailable and Faulted; every transition among those three is in
619+
* the table, so one matrix serves both.
600620
*/
601621
const VALID_TRANSITIONS: Record<string, Set<string>> = {
602-
Available: new Set(['Preparing', 'Charging', 'Reserved', 'Unavailable', 'Faulted']),
603-
Preparing: new Set(['Charging', 'Available', 'SuspendedEVSE', 'Faulted', 'Unavailable']),
604-
Charging: new Set(['SuspendedEVSE', 'SuspendedEV', 'Finishing', 'Available', 'Faulted']),
605-
SuspendedEVSE: new Set(['Charging', 'Finishing', 'Available', 'Faulted']),
606-
SuspendedEV: new Set(['Charging', 'Finishing', 'Available', 'Faulted']),
607-
Finishing: new Set(['Available', 'Reserved', 'Faulted']),
608-
Reserved: new Set(['Available', 'Unavailable', 'Faulted']),
609-
Unavailable: new Set(['Available', 'Faulted']),
610-
Faulted: new Set(['Unavailable', 'Available']),
622+
// A: A2, A3, A4, A5, A7, A8, A9
623+
Available: new Set([
624+
'Preparing',
625+
'Charging',
626+
'SuspendedEV',
627+
'SuspendedEVSE',
628+
'Reserved',
629+
'Unavailable',
630+
'Faulted',
631+
]),
632+
// B: B1, B3, B4, B5, B6, B9
633+
Preparing: new Set([
634+
'Available',
635+
'Charging',
636+
'SuspendedEV',
637+
'SuspendedEVSE',
638+
'Finishing',
639+
'Faulted',
640+
]),
641+
// C: C1, C4, C5, C6, C8, C9
642+
Charging: new Set([
643+
'Available',
644+
'SuspendedEV',
645+
'SuspendedEVSE',
646+
'Finishing',
647+
'Unavailable',
648+
'Faulted',
649+
]),
650+
// D: D1, D3, D5, D6, D8, D9
651+
SuspendedEV: new Set([
652+
'Available',
653+
'Charging',
654+
'SuspendedEVSE',
655+
'Finishing',
656+
'Unavailable',
657+
'Faulted',
658+
]),
659+
// E: E1, E3, E4, E6, E8, E9
660+
SuspendedEVSE: new Set([
661+
'Available',
662+
'Charging',
663+
'SuspendedEV',
664+
'Finishing',
665+
'Unavailable',
666+
'Faulted',
667+
]),
668+
// F: F1, F2, F8, F9
669+
Finishing: new Set(['Available', 'Preparing', 'Unavailable', 'Faulted']),
670+
// G: G1, G2, G8, G9
671+
Reserved: new Set(['Available', 'Preparing', 'Unavailable', 'Faulted']),
672+
// H: H1, H2, H3, H4, H5, H9
673+
Unavailable: new Set([
674+
'Available',
675+
'Preparing',
676+
'Charging',
677+
'SuspendedEV',
678+
'SuspendedEVSE',
679+
'Faulted',
680+
]),
681+
// I: I1 through I8
682+
Faulted: new Set([
683+
'Available',
684+
'Preparing',
685+
'Charging',
686+
'SuspendedEV',
687+
'SuspendedEVSE',
688+
'Finishing',
689+
'Reserved',
690+
'Unavailable',
691+
]),
611692
};
612693

613694
function detectStatusTransitionViolation(events: Event[]): Failure[] {

0 commit comments

Comments
 (0)