diff --git a/packages/toolkit/src/core/__fixtures__/connector-fault.ts b/packages/toolkit/src/core/__fixtures__/connector-fault.ts index d1ede1a..f091e7a 100644 --- a/packages/toolkit/src/core/__fixtures__/connector-fault.ts +++ b/packages/toolkit/src/core/__fixtures__/connector-fault.ts @@ -148,6 +148,16 @@ export default { direction: 'CSMS_TO_CS', message: [3, 'msg-006', {}], }, + { + timestamp: '2024-01-15T12:07:30.000Z', + direction: 'CS_TO_CSMS', + message: [2, 'msg-hb-1', 'Heartbeat', {}], + }, + { + timestamp: '2024-01-15T12:07:30.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-hb-1', { currentTime: '2024-01-15T12:07:30.500Z' }], + }, { timestamp: '2024-01-15T12:15:00.000Z', direction: 'CS_TO_CSMS', @@ -210,7 +220,7 @@ export default { idTag: 'SYNTHETIC-TAG-002', meterStop: 3500, timestamp: '2024-01-15T12:18:05.000Z', - reason: 'Faulted', + reason: 'Other', }, ], }, diff --git a/packages/toolkit/src/core/__fixtures__/normal-session.ts b/packages/toolkit/src/core/__fixtures__/normal-session.ts index 53db47a..65b8141 100644 --- a/packages/toolkit/src/core/__fixtures__/normal-session.ts +++ b/packages/toolkit/src/core/__fixtures__/normal-session.ts @@ -149,6 +149,16 @@ export default { direction: 'CSMS_TO_CS', message: [3, 'msg-006', {}], }, + { + timestamp: '2024-01-15T10:07:30.000Z', + direction: 'CS_TO_CSMS', + message: [2, 'msg-hb-1', 'Heartbeat', {}], + }, + { + timestamp: '2024-01-15T10:07:30.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-hb-1', { currentTime: '2024-01-15T10:07:30.500Z' }], + }, { timestamp: '2024-01-15T10:15:00.000Z', direction: 'CS_TO_CSMS', diff --git a/packages/toolkit/src/core/detection.test.ts b/packages/toolkit/src/core/detection.test.ts index 17c323c..dab0c14 100644 --- a/packages/toolkit/src/core/detection.test.ts +++ b/packages/toolkit/src/core/detection.test.ts @@ -441,4 +441,307 @@ describe('detectFailures', () => { expect(failures.some((f) => f.code === 'CONNECTOR_FAULT')).toBe(true); }); }); + + // ------------------------------------------------------------------------- + // v0.2 detection rules + // ------------------------------------------------------------------------- + + describe('TIMEOUT_NO_HEARTBEAT', () => { + it('detects missing heartbeat when trace spans beyond threshold', () => { + const events = [ + makeEvent('e1', 'm1', 'Call', 'BootNotification', {}, 0), + makeEvent( + 'e2', + 'm1', + 'CallResult', + null, + { interval: 300, status: 'Accepted' }, + 500, + 'CSMS_TO_CS', + ), + // No heartbeat, but events span beyond 600s (2x300) + makeEvent('e3', 'm2', 'Call', 'StatusNotification', { status: 'Available' }, 700_000), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'TIMEOUT_NO_HEARTBEAT')).toBe(true); + }); + + it('does not flag when heartbeat is present', () => { + const events = [ + makeEvent('e1', 'm1', 'Call', 'BootNotification', {}, 0), + makeEvent( + 'e2', + 'm1', + 'CallResult', + null, + { interval: 300, status: 'Accepted' }, + 500, + 'CSMS_TO_CS', + ), + makeEvent('e3', 'm2', 'Call', 'Heartbeat', {}, 100_000), + makeEvent('e4', 'm2', 'CallResult', null, {}, 100_500, 'CSMS_TO_CS'), + makeEvent('e5', 'm3', 'Call', 'StatusNotification', { status: 'Available' }, 700_000), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'TIMEOUT_NO_HEARTBEAT')).toBe(false); + }); + + it('does not flag when trace ends before first heartbeat is due', () => { + const events = [ + makeEvent('e1', 'm1', 'Call', 'BootNotification', {}, 0), + makeEvent( + 'e2', + 'm1', + 'CallResult', + null, + { interval: 300, status: 'Accepted' }, + 500, + 'CSMS_TO_CS', + ), + // Trace ends at 100s — before 600s threshold + makeEvent('e3', 'm2', 'Call', 'StatusNotification', { status: 'Available' }, 100_000), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'TIMEOUT_NO_HEARTBEAT')).toBe(false); + }); + + it('does not flag when no BootNotification', () => { + const events = [makeEvent('e1', 'm1', 'Call', 'Heartbeat', {}, 0)]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'TIMEOUT_NO_HEARTBEAT')).toBe(false); + }); + }); + + describe('METER_VALUE_GAP', () => { + it('detects missing MeterValues in completed transaction', () => { + const events = [ + makeEvent('e1', 'm1', 'Call', 'StartTransaction', { connectorId: 1, transactionId: 1 }, 0), + makeEvent('e2', 'm1', 'CallResult', null, { transactionId: 1 }, 500, 'CSMS_TO_CS'), + makeEvent( + 'e3', + 'm2', + 'Call', + 'StopTransaction', + { transactionId: 1, reason: 'EVDisconnected' }, + 10_000, + ), + makeEvent('e4', 'm2', 'CallResult', null, {}, 10_500, 'CSMS_TO_CS'), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'METER_VALUE_GAP')).toBe(true); + }); + + it('does not flag when MeterValues are present', () => { + const events = [ + makeEvent('e1', 'm1', 'Call', 'StartTransaction', { connectorId: 1, transactionId: 1 }, 0), + makeEvent('e2', 'm1', 'CallResult', null, { transactionId: 1 }, 500, 'CSMS_TO_CS'), + makeEvent('e3', 'm2', 'Call', 'MeterValues', { connectorId: 1, transactionId: 1 }, 5_000), + makeEvent('e4', 'm2', 'CallResult', null, {}, 5_500, 'CSMS_TO_CS'), + makeEvent( + 'e5', + 'm3', + 'Call', + 'StopTransaction', + { transactionId: 1, reason: 'EVDisconnected' }, + 10_000, + ), + makeEvent('e6', 'm3', 'CallResult', null, {}, 10_500, 'CSMS_TO_CS'), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'METER_VALUE_GAP')).toBe(false); + }); + }); + + describe('INVALID_STOP_REASON', () => { + it('detects invalid stop reason', () => { + const events = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'StopTransaction', + { transactionId: 1, reason: 'BadReason' }, + 0, + ), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'INVALID_STOP_REASON')).toBe(true); + }); + + it('does not flag valid stop reason', () => { + const events = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'StopTransaction', + { transactionId: 1, reason: 'EVDisconnected' }, + 0, + ), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'INVALID_STOP_REASON')).toBe(false); + }); + }); + + describe('UNEXPECTED_START', () => { + it('detects StartTransaction without BootNotification or Authorize', () => { + const events = [ + makeEvent('e1', 'm1', 'Call', 'StartTransaction', { connectorId: 1 }, 0), + makeEvent('e2', 'm1', 'CallResult', null, { transactionId: 1 }, 500, 'CSMS_TO_CS'), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'UNEXPECTED_START')).toBe(true); + }); + + it('does not flag when BootNotification precedes StartTransaction', () => { + const events = [ + makeEvent('e1', 'm1', 'Call', 'BootNotification', {}, 0), + makeEvent('e2', 'm1', 'CallResult', null, { status: 'Accepted' }, 500, 'CSMS_TO_CS'), + makeEvent('e3', 'm2', 'Call', 'StartTransaction', { connectorId: 1 }, 1000), + makeEvent('e4', 'm2', 'CallResult', null, { transactionId: 1 }, 1500, 'CSMS_TO_CS'), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'UNEXPECTED_START')).toBe(false); + }); + }); + + describe('STATUS_TRANSITION_VIOLATION', () => { + it('detects illegal transition from Available to Finishing', () => { + const events = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'StatusNotification', + { connectorId: 1, status: 'Available' }, + 0, + ), + makeEvent( + 'e2', + 'm2', + 'Call', + 'StatusNotification', + { connectorId: 1, status: 'Finishing' }, + 1000, + ), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'STATUS_TRANSITION_VIOLATION')).toBe(true); + }); + + it('does not flag valid transition from Available to Preparing', () => { + const events = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'StatusNotification', + { connectorId: 1, status: 'Available' }, + 0, + ), + makeEvent( + 'e2', + 'm2', + 'Call', + 'StatusNotification', + { connectorId: 1, status: 'Preparing' }, + 1000, + ), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'STATUS_TRANSITION_VIOLATION')).toBe(false); + }); + }); + + describe('DIAGNOSTICS_FAILURE', () => { + it('detects UploadFailed diagnostics status', () => { + const events = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'DiagnosticsStatusNotification', + { status: 'UploadFailed' }, + 0, + ), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'DIAGNOSTICS_FAILURE')).toBe(true); + }); + + it('detects DiagnosisFailed diagnostics status', () => { + const events = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'DiagnosticsStatusNotification', + { status: 'DiagnosisFailed' }, + 0, + ), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'DIAGNOSTICS_FAILURE')).toBe(true); + }); + + it('does not flag Uploaded diagnostics status', () => { + const events = [ + makeEvent('e1', 'm1', 'Call', 'DiagnosticsStatusNotification', { status: 'Uploaded' }, 0), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'DIAGNOSTICS_FAILURE')).toBe(false); + }); + }); + + describe('FIRMWARE_UPDATE_FAILURE', () => { + it('detects DownloadFailed firmware status', () => { + const events = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'FirmwareStatusNotification', + { status: 'DownloadFailed' }, + 0, + ), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'FIRMWARE_UPDATE_FAILURE')).toBe(true); + }); + + it('detects InstallFailed firmware status', () => { + const events = [ + makeEvent('e1', 'm1', 'Call', 'FirmwareStatusNotification', { status: 'InstallFailed' }, 0), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'FIRMWARE_UPDATE_FAILURE')).toBe(true); + }); + + it('does not flag Downloaded firmware status', () => { + const events = [ + makeEvent('e1', 'm1', 'Call', 'FirmwareStatusNotification', { status: 'Downloaded' }, 0), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + expect(failures.some((f) => f.code === 'FIRMWARE_UPDATE_FAILURE')).toBe(false); + }); + }); }); diff --git a/packages/toolkit/src/core/detection.ts b/packages/toolkit/src/core/detection.ts index 965e4aa..0f53a76 100644 --- a/packages/toolkit/src/core/detection.ts +++ b/packages/toolkit/src/core/detection.ts @@ -1,11 +1,21 @@ /** * Failure detection — analyzes events and sessions for known failure patterns. * - * Three detection rules in v0.1: + * 10 detection rules (v0.1 + v0.2): + * + * v0.1: * 1. FAILED_AUTHORIZATION — Authorize response with idTagInfo.status = "Invalid" * 2. CONNECTOR_FAULT — StatusNotification with status = "Faulted" during active session - * 3. STATION_OFFLINE_DURING_SESSION — session has StartTransaction but no StopTransaction, - * or connector transitions to Unavailable/Offline during an active transaction + * 3. STATION_OFFLINE_DURING_SESSION — session has StartTransaction but no StopTransaction + * + * v0.2: + * 4. TIMEOUT_NO_HEARTBEAT — no Heartbeat within expected interval after BootNotification + * 5. METER_VALUE_GAP — no MeterValues during an active transaction + * 6. INVALID_STOP_REASON — StopTransaction with unexpected/invalid stop reason + * 7. UNEXPECTED_START — StartTransaction without preceding BootNotification or Authorize + * 8. STATUS_TRANSITION_VIOLATION — illegal connector status transition + * 9. DIAGNOSTICS_FAILURE — DiagnosticsStatusNotification indicating failure + * 10. FIRMWARE_UPDATE_FAILURE — FirmwareStatusNotification indicating failure * * @see ADR-0003 */ @@ -37,12 +47,64 @@ const SUGGESTED_STEPS: Record = { 'Check if the station firmware has a known stability issue', 'Investigate if maintenance was performed on the station', ], + TIMEOUT_NO_HEARTBEAT: [ + 'Check the station network connectivity', + 'Verify the WebSocket connection is stable', + 'Review the station heartbeat interval configuration', + 'Check if the station has rebooted or lost power', + 'Inspect the CSMS for connection acceptance issues', + ], + METER_VALUE_GAP: [ + 'Verify the meter is functioning correctly', + 'Check the meter value reporting interval configuration', + 'Inspect the OCPP connection stability during the session', + 'Review station logs for meter communication errors', + 'Consider hardware replacement if meter is faulty', + ], + INVALID_STOP_REASON: [ + 'Review the StopTransaction payload for the stop reason', + 'Check if the stop reason is within the OCPP 1.6 specification', + 'Investigate why the station used a non-standard reason', + 'Review station firmware for stop reason mapping bugs', + ], + UNEXPECTED_START: [ + 'Verify the station performed BootNotification before starting a transaction', + 'Check if authorization was properly completed before StartTransaction', + 'Review the station startup sequence and timing', + 'Inspect the CSMS for delayed or missing responses', + ], + STATUS_TRANSITION_VIOLATION: [ + 'Review the connector status transition sequence', + 'Check if the station firmware follows the OCPP status model correctly', + 'Verify no manual overrides triggered invalid transitions', + 'Inspect the connector status history for anomalies', + ], + DIAGNOSTICS_FAILURE: [ + 'Review the DiagnosticsStatusNotification payload for the specific status', + 'Check the station diagnostic logs for detailed error information', + 'Verify the station hardware diagnostics are passing', + 'Contact hardware vendor if diagnostics indicate hardware failure', + ], + FIRMWARE_UPDATE_FAILURE: [ + 'Review the FirmwareStatusNotification payload for the specific status', + 'Check if the firmware image was corrupted or incomplete', + 'Verify the station has sufficient storage for the firmware update', + 'Retry the firmware update after addressing the failure cause', + 'Contact the firmware provider if the image is defective', + ], }; const SEVERITY: Record = { FAILED_AUTHORIZATION: 'warning', CONNECTOR_FAULT: 'critical', STATION_OFFLINE_DURING_SESSION: 'critical', + TIMEOUT_NO_HEARTBEAT: 'warning', + METER_VALUE_GAP: 'warning', + INVALID_STOP_REASON: 'info', + UNEXPECTED_START: 'warning', + STATUS_TRANSITION_VIOLATION: 'warning', + DIAGNOSTICS_FAILURE: 'critical', + FIRMWARE_UPDATE_FAILURE: 'warning', }; // --------------------------------------------------------------------------- @@ -251,14 +313,344 @@ function detectStationOfflineDuringSession(_events: Event[], sessions: Session[] export function detectFailures(events: Event[], sessions: Session[]): Failure[] { const failures: Failure[] = []; - // Rule 1: Failed authorization + // v0.1 rules failures.push(...detectFailedAuthorization(events)); - - // Rule 2: Connector fault during active session failures.push(...detectConnectorFault(events)); - - // Rule 3: Station offline during session failures.push(...detectStationOfflineDuringSession(events, sessions)); + // v0.2 rules + failures.push(...detectTimeoutNoHeartbeat(events)); + failures.push(...detectMeterValueGap(events, sessions)); + failures.push(...detectInvalidStopReason(events)); + failures.push(...detectUnexpectedStart(events)); + failures.push(...detectStatusTransitionViolation(events)); + failures.push(...detectDiagnosticsFailure(events)); + failures.push(...detectFirmwareUpdateFailure(events)); + + return failures; +} + +// --------------------------------------------------------------------------- +// v0.2 detection rules +// --------------------------------------------------------------------------- + +/** Default heartbeat interval: 60 seconds (OCPP 1.6 default). */ +const DEFAULT_HEARTBEAT_INTERVAL_MS = 60_000; + +/** Valid OCPP 1.6 StopTransaction reasons. */ +const VALID_STOP_REASONS = new Set([ + 'EmergencyStop', + 'EVDisconnected', + 'HardReset', + 'Local', + 'Other', + 'PowerLoss', + 'Reboot', + 'Remote', + 'SoftReset', + 'UnlockCommand', + 'DeAuthorized', +]); + +/** Valid connector statuses per OCPP 1.6. */ +const VALID_CONNECTOR_STATUSES = new Set([ + 'Available', + 'Preparing', + 'Charging', + 'SuspendedEVSE', + 'SuspendedEV', + 'Finishing', + 'Reserved', + 'Unavailable', + 'Faulted', +]); + +/** + * Rule 4: TIMEOUT_NO_HEARTBEAT + * Detects when a station sends no Heartbeat within the expected interval + * after BootNotification. Uses 2x the default interval as the threshold. + */ +function detectTimeoutNoHeartbeat(events: Event[]): Failure[] { + const failures: Failure[] = []; + + const bootIndex = events.findIndex( + (e) => e.messageType === 'Call' && e.action === 'BootNotification', + ); + if (bootIndex === -1) return failures; + + const bootEvent = events[bootIndex]; + if (!bootEvent || bootEvent.timestamp === null) return failures; + + // Extract heartbeat interval from BootNotification response if present + let intervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS; + const bootResponse = events.find( + (e) => + e.messageType === 'CallResult' && + e.messageId === bootEvent.messageId && + typeof (e.payload as { interval?: unknown })?.interval === 'number', + ); + if (bootResponse) { + const interval = (bootResponse.payload as { interval: number }).interval; + intervalMs = interval * 1000; + } + + // Look for any Heartbeat within 2x the expected interval after BootNotification + const threshold = bootEvent.timestamp + intervalMs * 2; + + // Only flag if the trace has events beyond the heartbeat threshold + // (if the trace ends before the first heartbeat is due, we can't know + // if the station would have sent one) + const hasEventsBeyondThreshold = events.some( + (e) => e.timestamp !== null && e.timestamp > threshold, + ); + + if (!hasEventsBeyondThreshold) return failures; + + const hasHeartbeat = events.some( + (e) => + e.messageType === 'Call' && + e.action === 'Heartbeat' && + e.timestamp !== null && + e.timestamp <= threshold, + ); + + if (!hasHeartbeat) { + failures.push({ + code: 'TIMEOUT_NO_HEARTBEAT', + description: `No Heartbeat received within ${(intervalMs * 2) / 1000}s of BootNotification (expected every ${intervalMs / 1000}s)`, + severity: SEVERITY.TIMEOUT_NO_HEARTBEAT, + eventIds: [bootEvent.id], + suggestedSteps: SUGGESTED_STEPS.TIMEOUT_NO_HEARTBEAT, + }); + } + + return failures; +} + +/** + * Rule 5: METER_VALUE_GAP + * Detects active transactions (StartTransaction to StopTransaction) where + * no MeterValuesRequest was sent. + */ +function detectMeterValueGap(_events: Event[], sessions: Session[]): Failure[] { + const failures: Failure[] = []; + + for (const session of sessions) { + if (session.transactionId === null) continue; + + const hasStart = session.events.some( + (e) => e.messageType === 'Call' && e.action === 'StartTransaction', + ); + const hasStop = session.events.some( + (e) => e.messageType === 'Call' && e.action === 'StopTransaction', + ); + + if (!hasStart || !hasStop) continue; + + const hasMeterValues = session.events.some( + (e) => e.messageType === 'Call' && e.action === 'MeterValues', + ); + + if (!hasMeterValues) { + const startEvent = session.events.find( + (e) => e.messageType === 'Call' && e.action === 'StartTransaction', + ); + failures.push({ + code: 'METER_VALUE_GAP', + description: `Session ${session.sessionId} (transaction ${session.transactionId}) has StartTransaction and StopTransaction but no MeterValues — metering data is missing`, + severity: SEVERITY.METER_VALUE_GAP, + eventIds: startEvent ? [startEvent.id] : [], + suggestedSteps: SUGGESTED_STEPS.METER_VALUE_GAP, + }); + } + } + + return failures; +} + +/** + * Rule 6: INVALID_STOP_REASON + * Detects StopTransaction with a stop reason not in the OCPP 1.6 specification. + */ +function detectInvalidStopReason(events: Event[]): Failure[] { + const failures: Failure[] = []; + + for (const event of events) { + if (event.messageType !== 'Call' || event.action !== 'StopTransaction') continue; + + const payload = event.payload as { reason?: unknown }; + const reason = payload?.reason; + + if (typeof reason === 'string' && !VALID_STOP_REASONS.has(reason)) { + failures.push({ + code: 'INVALID_STOP_REASON', + description: `StopTransaction has invalid stop reason "${reason}" — not a valid OCPP 1.6 reason code (messageId: ${event.messageId})`, + severity: SEVERITY.INVALID_STOP_REASON, + eventIds: [event.id], + suggestedSteps: SUGGESTED_STEPS.INVALID_STOP_REASON, + }); + } + } + + return failures; +} + +/** + * Rule 7: UNEXPECTED_START + * Detects StartTransaction without a preceding BootNotification or Authorize. + */ +function detectUnexpectedStart(events: Event[]): Failure[] { + const failures: Failure[] = []; + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + if (!event) continue; + if (event.messageType !== 'Call' || event.action !== 'StartTransaction') continue; + + // Check for BootNotification or Authorize before this StartTransaction + const hasBoot = events + .slice(0, i) + .some((e) => e.messageType === 'Call' && e.action === 'BootNotification'); + const hasAuthorize = events + .slice(0, i) + .some((e) => e.messageType === 'Call' && e.action === 'Authorize'); + + if (!hasBoot && !hasAuthorize) { + failures.push({ + code: 'UNEXPECTED_START', + description: `StartTransaction at event ${event.id} without preceding BootNotification or Authorize — transaction started without proper initialization (messageId: ${event.messageId})`, + severity: SEVERITY.UNEXPECTED_START, + eventIds: [event.id], + suggestedSteps: SUGGESTED_STEPS.UNEXPECTED_START, + }); + } + } + + return failures; +} + +/** + * Rule 8: STATUS_TRANSITION_VIOLATION + * Detects illegal connector status transitions. + * Valid transitions are based on the OCPP 1.6 connector state model. + */ +const VALID_TRANSITIONS: Record> = { + Available: new Set(['Preparing', 'Charging', 'Reserved', 'Unavailable', 'Faulted']), + Preparing: new Set(['Charging', 'Available', 'SuspendedEVSE', 'Faulted', 'Unavailable']), + Charging: new Set(['SuspendedEVSE', 'SuspendedEV', 'Finishing', 'Available', 'Faulted']), + SuspendedEVSE: new Set(['Charging', 'Finishing', 'Available', 'Faulted']), + SuspendedEV: new Set(['Charging', 'Finishing', 'Available', 'Faulted']), + Finishing: new Set(['Available', 'Reserved', 'Faulted']), + Reserved: new Set(['Available', 'Unavailable', 'Faulted']), + Unavailable: new Set(['Available', 'Faulted']), + Faulted: new Set(['Unavailable', 'Available']), +}; + +function detectStatusTransitionViolation(events: Event[]): Failure[] { + const failures: Failure[] = []; + + // Collect all StatusNotification statuses in order + const statusEvents = events.filter( + (e) => e.messageType === 'Call' && e.action === 'StatusNotification', + ); + + let prevStatus: string | null = null; + let prevEvent: Event | null = null; + + for (const event of statusEvents) { + const payload = event.payload as { status?: unknown }; + const status = payload?.status; + + if (typeof status !== 'string' || !VALID_CONNECTOR_STATUSES.has(status)) continue; + + if (prevStatus !== null && prevEvent !== null) { + const allowed: Set | undefined = VALID_TRANSITIONS[prevStatus]; + if (allowed && !allowed.has(status)) { + failures.push({ + code: 'STATUS_TRANSITION_VIOLATION', + description: `Connector status transition from "${prevStatus}" to "${status}" is not a valid OCPP 1.6 transition (messageId: ${event.messageId})`, + severity: SEVERITY.STATUS_TRANSITION_VIOLATION, + eventIds: [prevEvent.id, event.id], + suggestedSteps: SUGGESTED_STEPS.STATUS_TRANSITION_VIOLATION, + }); + } + } + + prevStatus = status; + prevEvent = event; + } + + return failures; +} + +/** + * Rule 9: DIAGNOSTICS_FAILURE + * Detects DiagnosticsStatusNotification with a failure status. + */ +const DIAGNOSTICS_FAILURE_STATUSES = new Set([ + 'Idle', + 'Uploaded', + 'UploadFailed', + 'DiagnosisFailed', + 'NotImplemented', +]); + +function detectDiagnosticsFailure(events: Event[]): Failure[] { + const failures: Failure[] = []; + + for (const event of events) { + if (event.messageType !== 'Call' || event.action !== 'DiagnosticsStatusNotification') continue; + + const payload = event.payload as { status?: unknown }; + const status = payload?.status; + + if (typeof status === 'string' && DIAGNOSTICS_FAILURE_STATUSES.has(status)) { + // Only report actual failures, not successful diagnostics + if (status === 'UploadFailed' || status === 'DiagnosisFailed') { + failures.push({ + code: 'DIAGNOSTICS_FAILURE', + description: `DiagnosticsStatusNotification reported failure status "${status}" (messageId: ${event.messageId})`, + severity: SEVERITY.DIAGNOSTICS_FAILURE, + eventIds: [event.id], + suggestedSteps: SUGGESTED_STEPS.DIAGNOSTICS_FAILURE, + }); + } + } + } + + return failures; +} + +/** + * Rule 10: FIRMWARE_UPDATE_FAILURE + * Detects FirmwareStatusNotification indicating a firmware update failure. + */ +const FIRMWARE_FAILURE_STATUSES = new Set([ + 'DownloadFailed', + 'DownloadPaused', + 'InstallFailed', + 'InstallRebootingFailed', +]); + +function detectFirmwareUpdateFailure(events: Event[]): Failure[] { + const failures: Failure[] = []; + + for (const event of events) { + if (event.messageType !== 'Call' || event.action !== 'FirmwareStatusNotification') continue; + + const payload = event.payload as { status?: unknown }; + const status = payload?.status; + + if (typeof status === 'string' && FIRMWARE_FAILURE_STATUSES.has(status)) { + failures.push({ + code: 'FIRMWARE_UPDATE_FAILURE', + description: `FirmwareStatusNotification reported failure status "${status}" (messageId: ${event.messageId})`, + severity: SEVERITY.FIRMWARE_UPDATE_FAILURE, + eventIds: [event.id], + suggestedSteps: SUGGESTED_STEPS.FIRMWARE_UPDATE_FAILURE, + }); + } + } + return failures; } diff --git a/packages/toolkit/src/core/fixtures.test.ts b/packages/toolkit/src/core/fixtures.test.ts index 1bedc88..5d24801 100644 --- a/packages/toolkit/src/core/fixtures.test.ts +++ b/packages/toolkit/src/core/fixtures.test.ts @@ -268,7 +268,7 @@ describe('Synthetic trace fixtures', () => { expect(payload.errorCode).not.toBe('NoError'); }); - it('has StopTransaction with Faulted reason after connector fault', () => { + it('has StopTransaction with Other reason after connector fault', () => { const stopTx = connectorFault.events.find( (e) => e.message[0] === 2 && e.message[2] === 'StopTransaction', ); @@ -277,7 +277,7 @@ describe('Synthetic trace fixtures', () => { const payload = (stopTx as TraceEventInput).message[3] as { reason?: string; }; - expect(payload.reason).toBe('Faulted'); + expect(payload.reason).toBe('Other'); }); it('has a StartTransaction before the StopTransaction', () => { diff --git a/packages/toolkit/src/core/types.ts b/packages/toolkit/src/core/types.ts index e656753..ef8edf3 100644 --- a/packages/toolkit/src/core/types.ts +++ b/packages/toolkit/src/core/types.ts @@ -149,9 +149,19 @@ export interface ParseResult { /** Severity of a detected failure. */ export type FailureSeverity = 'critical' | 'warning' | 'info'; -/** Failure rule codes implemented in v0.1. */ +/** Failure rule codes. v0.1 codes + v0.2 additions. */ export type FailureCode = - 'FAILED_AUTHORIZATION' | 'CONNECTOR_FAULT' | 'STATION_OFFLINE_DURING_SESSION'; + | 'FAILED_AUTHORIZATION' + | 'CONNECTOR_FAULT' + | 'STATION_OFFLINE_DURING_SESSION' + // v0.2 rules + | 'TIMEOUT_NO_HEARTBEAT' + | 'METER_VALUE_GAP' + | 'INVALID_STOP_REASON' + | 'UNEXPECTED_START' + | 'STATUS_TRANSITION_VIOLATION' + | 'DIAGNOSTICS_FAILURE' + | 'FIRMWARE_UPDATE_FAILURE'; /** * A detected failure in a trace. diff --git a/packages/toolkit/src/scenarios/__scenarios__/station-offline.ts b/packages/toolkit/src/scenarios/__scenarios__/station-offline.ts index 4f3c622..a58e41f 100644 --- a/packages/toolkit/src/scenarios/__scenarios__/station-offline.ts +++ b/packages/toolkit/src/scenarios/__scenarios__/station-offline.ts @@ -133,6 +133,16 @@ export default { direction: 'CSMS_TO_CS', message: [3, 'msg-005', {}], }, + { + timestamp: '2024-01-15T14:07:30.000Z', + direction: 'CS_TO_CSMS', + message: [2, 'msg-hb-1', 'Heartbeat', {}], + }, + { + timestamp: '2024-01-15T14:07:30.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-hb-1', { currentTime: '2024-01-15T14:07:30.500Z' }], + }, { timestamp: '2024-01-15T14:15:00.000Z', direction: 'CS_TO_CSMS', diff --git a/packages/toolkit/src/scenarios/__scenarios__/unexpected-stop-reason.ts b/packages/toolkit/src/scenarios/__scenarios__/unexpected-stop-reason.ts index eec8e33..a217436 100644 --- a/packages/toolkit/src/scenarios/__scenarios__/unexpected-stop-reason.ts +++ b/packages/toolkit/src/scenarios/__scenarios__/unexpected-stop-reason.ts @@ -133,6 +133,16 @@ export default { direction: 'CSMS_TO_CS', message: [3, 'msg-005', {}], }, + { + timestamp: '2024-01-15T16:07:30.000Z', + direction: 'CS_TO_CSMS', + message: [2, 'msg-hb-1', 'Heartbeat', {}], + }, + { + timestamp: '2024-01-15T16:07:30.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-hb-1', { currentTime: '2024-01-15T16:07:30.500Z' }], + }, { timestamp: '2024-01-15T16:20:00.000Z', direction: 'CS_TO_CSMS',