diff --git a/.changeset/core-timeline-detection.md b/.changeset/core-timeline-detection.md new file mode 100644 index 0000000..4aaf281 --- /dev/null +++ b/.changeset/core-timeline-detection.md @@ -0,0 +1,12 @@ +--- +'@ocpp-debugkit/core': minor +--- + +Implement session timeline, failure detection, summarizer, and validator. + +- `buildSessionTimeline()` correlates events into sessions by transactionId (ADR-0006) +- `detectFailures()` implements 3 detection rules: FAILED_AUTHORIZATION, + CONNECTOR_FAULT, STATION_OFFLINE_DURING_SESSION +- `summarizeSession()` / `summarizeSessions()` produce overview statistics +- `validateMessage()` / `validateMessages()` check OCPP 1.6 JSON structural compliance +- 40 new unit tests (10 timeline + 11 detection + 5 summarizer + 14 validator) diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index d1672e1..aa9daf1 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -61,20 +61,26 @@ report exported. CLI and web inspector. - ✅ 3 synthetic trace fixtures in `packages/core/src/__fixtures__/` - ✅ Proposed canonical types and fixture validation tests -### Core Package — Data Model + Parser + Normalizer (in progress — this PR) +### Core Package — Data Model + Parser + Normalizer (PR #33) - ✅ `packages/core/src/schemas.ts` — Zod schemas for all input types - ✅ `packages/core/src/normalizer.ts` — `normalizeEvents()`, direction inference (ADR-0004), timestamp normalization (ADR-0005) - ✅ `packages/core/src/parser.ts` — `parseTrace()` accepting JSON Object, JSONL, bare array - ✅ `packages/core/src/types.ts` — updated with `Failure`, `Scenario`, `SessionSummary`, `ValidationResult` types -- ✅ `packages/core/src/normalizer.test.ts` — 46 tests -- ✅ `packages/core/src/parser.test.ts` — 32 tests -- ✅ Untrusted-input handling: safe JSON parsing, size limits (10 MB), event count limits (10,000), prototype pollution protection, malformed event skip-and-flag +- ✅ 78 unit tests (46 normalizer + 32 parser) + +### Core Package — Timeline + Detection + Summarizer + Validator (in progress — this PR) + +- ✅ `packages/core/src/timeline.ts` — `buildSessionTimeline()` correlating events by `transactionId` (ADR-0006) +- ✅ `packages/core/src/detection.ts` — `detectFailures()` with 3 rules: `FAILED_AUTHORIZATION`, `CONNECTOR_FAULT`, `STATION_OFFLINE_DURING_SESSION` +- ✅ `packages/core/src/summarizer.ts` — `summarizeSession()` / `summarizeSessions()` producing overview stats +- ✅ `packages/core/src/validator.ts` — `validateMessage()` / `validateMessages()` checking OCPP 1.6 JSON structural compliance +- ✅ 40 additional tests (10 timeline + 11 detection + 5 summarizer + 14 validator) ## What's Next -1. **Issue #20** (this PR) → complete: data model + parser + normalizer -2. **Issue #21**: Core timeline + failure detection + summarizer + validator +1. **Issue #20** → complete (PR #33): data model + parser + normalizer +2. **Issue #21** (this PR) → complete: timeline + detection + summarizer + validator 3. **Issue #22**: Core public API export + package config 4. **Issue #23**: Scenarios package (format + 5 initial scenarios) 5. **Issue #24**: Reporter package (Markdown report generator) @@ -88,7 +94,7 @@ report exported. CLI and web inspector. | Package | Status | Version | |---------|--------|---------| -| `@ocpp-debugkit/core` | in progress (parser + normalizer done) | 0.0.0 | +| `@ocpp-debugkit/core` | in progress (parser + timeline + detection + summarizer + validator) | 0.0.0 | | `@ocpp-debugkit/scenarios` | not started | — | | `@ocpp-debugkit/reporter` | not started | — | | `@ocpp-debugkit/cli` | not started | — | diff --git a/packages/core/src/detection.test.ts b/packages/core/src/detection.test.ts new file mode 100644 index 0000000..17c323c --- /dev/null +++ b/packages/core/src/detection.test.ts @@ -0,0 +1,444 @@ +import { describe, it, expect } from 'vitest'; +import { detectFailures } from './detection.js'; +import { buildSessionTimeline } from './timeline.js'; +import { parseTrace } from './parser.js'; +import type { Event, RawOcppMessage } from './types.js'; + +// Helpers +function makeEvent( + id: string, + messageId: string, + messageType: 'Call' | 'CallResult' | 'CallError', + action: string | null, + payload: unknown = {}, + timestamp: number | null = null, + direction: 'CS_TO_CSMS' | 'CSMS_TO_CS' | 'UNKNOWN' = 'CS_TO_CSMS', +): Event { + let rawMessage: RawOcppMessage; + if (messageType === 'Call') { + rawMessage = [2, messageId, action as string, payload]; + } else if (messageType === 'CallResult') { + rawMessage = [3, messageId, payload]; + } else { + rawMessage = [4, messageId, 'Error', 'desc', payload]; + } + return { + id, + messageId, + timestamp, + direction, + messageType, + action, + payload, + errorCode: messageType === 'CallError' ? 'Error' : null, + errorDescription: messageType === 'CallError' ? 'desc' : null, + rawMessage, + }; +} + +describe('detectFailures', () => { + describe('FAILED_AUTHORIZATION', () => { + it('detects rejected Authorize response', () => { + const events = [ + makeEvent( + 'evt-0001', + 'msg-001', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'evt-0002', + 'msg-001', + 'CallResult', + null, + { status: 'Accepted' }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent('evt-0003', 'msg-002', 'Call', 'Authorize', { idTag: 'BAD-TAG' }, 2000), + makeEvent( + 'evt-0004', + 'msg-002', + 'CallResult', + null, + { idTagInfo: { status: 'Invalid' } }, + 2500, + 'CSMS_TO_CS', + ), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + + const authFailures = failures.filter((f) => f.code === 'FAILED_AUTHORIZATION'); + expect(authFailures).toHaveLength(1); + expect(authFailures[0]?.severity).toBe('warning'); + expect(authFailures[0]?.eventIds).toContain('evt-0003'); + expect(authFailures[0]?.eventIds).toContain('evt-0004'); + expect(authFailures[0]?.suggestedSteps.length).toBeGreaterThan(0); + }); + + it('does not flag accepted Authorize', () => { + const events = [ + makeEvent('evt-0001', 'msg-001', 'Call', 'Authorize', { idTag: 'GOOD-TAG' }, 1000), + makeEvent( + 'evt-0002', + 'msg-001', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' } }, + 1500, + 'CSMS_TO_CS', + ), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + + expect(failures.filter((f) => f.code === 'FAILED_AUTHORIZATION')).toHaveLength(0); + }); + + it('detects multiple failed authorizations', () => { + const events = [ + makeEvent('evt-0001', 'msg-001', 'Call', 'Authorize', { idTag: 'BAD-1' }, 1000), + makeEvent( + 'evt-0002', + 'msg-001', + 'CallResult', + null, + { idTagInfo: { status: 'Invalid' } }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent('evt-0003', 'msg-002', 'Call', 'Authorize', { idTag: 'BAD-2' }, 2000), + makeEvent( + 'evt-0004', + 'msg-002', + 'CallResult', + null, + { idTagInfo: { status: 'Invalid' } }, + 2500, + 'CSMS_TO_CS', + ), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + + expect(failures.filter((f) => f.code === 'FAILED_AUTHORIZATION')).toHaveLength(2); + }); + }); + + describe('CONNECTOR_FAULT', () => { + it('detects faulted connector during active session', () => { + const events = [ + makeEvent( + 'evt-0001', + 'msg-001', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'evt-0002', + 'msg-001', + 'CallResult', + null, + { status: 'Accepted' }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0003', + 'msg-002', + 'Call', + 'StartTransaction', + { connectorId: 1, idTag: 'TAG-001', meterStart: 0 }, + 2000, + ), + makeEvent( + 'evt-0004', + 'msg-002', + 'CallResult', + null, + { transactionId: 100001, idTagInfo: { status: 'Accepted' } }, + 2500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0005', + 'msg-003', + 'Call', + 'StatusNotification', + { connectorId: 1, status: 'Faulted', errorCode: 'ConnectorLockFailure' }, + 3000, + ), + makeEvent('evt-0006', 'msg-003', 'CallResult', null, {}, 3500, 'CSMS_TO_CS'), + makeEvent( + 'evt-0007', + 'msg-004', + 'Call', + 'StopTransaction', + { transactionId: 100001, meterStop: 5000, reason: 'Faulted' }, + 4000, + ), + makeEvent( + 'evt-0008', + 'msg-004', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' } }, + 4500, + 'CSMS_TO_CS', + ), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + + const faultFailures = failures.filter((f) => f.code === 'CONNECTOR_FAULT'); + expect(faultFailures).toHaveLength(1); + expect(faultFailures[0]?.severity).toBe('critical'); + expect(faultFailures[0]?.eventIds).toContain('evt-0003'); // StartTransaction + expect(faultFailures[0]?.eventIds).toContain('evt-0005'); // Faulted StatusNotification + }); + + it('does not flag faulted connector outside active session', () => { + const events = [ + makeEvent( + 'evt-0001', + 'msg-001', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'evt-0002', + 'msg-001', + 'CallResult', + null, + { status: 'Accepted' }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0003', + 'msg-002', + 'Call', + 'StatusNotification', + { connectorId: 1, status: 'Faulted', errorCode: 'OtherError' }, + 2000, + ), + makeEvent('evt-0004', 'msg-002', 'CallResult', null, {}, 2500, 'CSMS_TO_CS'), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + + expect(failures.filter((f) => f.code === 'CONNECTOR_FAULT')).toHaveLength(0); + }); + }); + + describe('STATION_OFFLINE_DURING_SESSION', () => { + it('detects session without StopTransaction', () => { + const events = [ + makeEvent( + 'evt-0001', + 'msg-001', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'evt-0002', + 'msg-001', + 'CallResult', + null, + { status: 'Accepted' }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0003', + 'msg-002', + 'Call', + 'StartTransaction', + { connectorId: 1, idTag: 'TAG-001', meterStart: 0 }, + 2000, + ), + makeEvent( + 'evt-0004', + 'msg-002', + 'CallResult', + null, + { transactionId: 100001, idTagInfo: { status: 'Accepted' } }, + 2500, + 'CSMS_TO_CS', + ), + // No StopTransaction — session is active + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + + const offlineFailures = failures.filter((f) => f.code === 'STATION_OFFLINE_DURING_SESSION'); + expect(offlineFailures).toHaveLength(1); + expect(offlineFailures[0]?.severity).toBe('critical'); + }); + + it('does not flag completed sessions', () => { + const events = [ + makeEvent( + 'evt-0001', + 'msg-001', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'evt-0002', + 'msg-001', + 'CallResult', + null, + { status: 'Accepted' }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0003', + 'msg-002', + 'Call', + 'StartTransaction', + { connectorId: 1, idTag: 'TAG-001', meterStart: 0 }, + 2000, + ), + makeEvent( + 'evt-0004', + 'msg-002', + 'CallResult', + null, + { transactionId: 100001, idTagInfo: { status: 'Accepted' } }, + 2500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0005', + 'msg-003', + 'Call', + 'StopTransaction', + { transactionId: 100001, meterStop: 5000, reason: 'EVDisconnected' }, + 3000, + ), + makeEvent( + 'evt-0006', + 'msg-003', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' } }, + 3500, + 'CSMS_TO_CS', + ), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + + expect(failures.filter((f) => f.code === 'STATION_OFFLINE_DURING_SESSION')).toHaveLength(0); + }); + + it('detects Unavailable status during active session', () => { + const events = [ + makeEvent( + 'evt-0001', + 'msg-001', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'evt-0002', + 'msg-001', + 'CallResult', + null, + { status: 'Accepted' }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0003', + 'msg-002', + 'Call', + 'StartTransaction', + { connectorId: 1, idTag: 'TAG-001', meterStart: 0 }, + 2000, + ), + makeEvent( + 'evt-0004', + 'msg-002', + 'CallResult', + null, + { transactionId: 100001, idTagInfo: { status: 'Accepted' } }, + 2500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0005', + 'msg-003', + 'Call', + 'StatusNotification', + { connectorId: 1, status: 'Unavailable' }, + 3000, + ), + makeEvent('evt-0006', 'msg-003', 'CallResult', null, {}, 3500, 'CSMS_TO_CS'), + makeEvent( + 'evt-0007', + 'msg-004', + 'Call', + 'StopTransaction', + { transactionId: 100001, meterStop: 5000, reason: 'Other' }, + 4000, + ), + makeEvent( + 'evt-0008', + 'msg-004', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' } }, + 4500, + 'CSMS_TO_CS', + ), + ]; + const sessions = buildSessionTimeline(events); + const failures = detectFailures(events, sessions); + + const offlineFailures = failures.filter((f) => f.code === 'STATION_OFFLINE_DURING_SESSION'); + expect(offlineFailures).toHaveLength(1); + }); + }); + + describe('fixture integration', () => { + it('detects no failures in normal-session fixture', async () => { + const { normalSession } = await import('./fixtures/index.js'); + const result = parseTrace(JSON.stringify(normalSession)); + const sessions = buildSessionTimeline(result.events); + const failures = detectFailures(result.events, sessions); + expect(failures).toHaveLength(0); + }); + + it('detects FAILED_AUTHORIZATION in failed-auth fixture', async () => { + const { failedAuth } = await import('./fixtures/index.js'); + const result = parseTrace(JSON.stringify(failedAuth)); + const sessions = buildSessionTimeline(result.events); + const failures = detectFailures(result.events, sessions); + expect(failures.some((f) => f.code === 'FAILED_AUTHORIZATION')).toBe(true); + }); + + it('detects CONNECTOR_FAULT in connector-fault fixture', async () => { + const { connectorFault } = await import('./fixtures/index.js'); + const result = parseTrace(JSON.stringify(connectorFault)); + const sessions = buildSessionTimeline(result.events); + const failures = detectFailures(result.events, sessions); + expect(failures.some((f) => f.code === 'CONNECTOR_FAULT')).toBe(true); + }); + }); +}); diff --git a/packages/core/src/detection.ts b/packages/core/src/detection.ts new file mode 100644 index 0000000..965e4aa --- /dev/null +++ b/packages/core/src/detection.ts @@ -0,0 +1,264 @@ +/** + * Failure detection — analyzes events and sessions for known failure patterns. + * + * Three detection rules in 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 + * + * @see ADR-0003 + */ + +import type { Event, Failure, FailureCode, FailureSeverity, Session } from './types.js'; + +// --------------------------------------------------------------------------- +// Suggested steps per failure code +// --------------------------------------------------------------------------- + +const SUGGESTED_STEPS: Record = { + FAILED_AUTHORIZATION: [ + 'Verify the idTag is valid and not expired', + 'Check the CSMS local authorization list', + 'Ensure the idTag is not blocked or deactivated', + 'Review the Authorize response payload for rejection reason', + ], + CONNECTOR_FAULT: [ + 'Inspect the physical connector for damage or debris', + 'Check the connector lock mechanism', + 'Review the errorCode field for specific fault type', + 'Check station logs for hardware diagnostics', + 'Contact hardware vendor if fault persists', + ], + STATION_OFFLINE_DURING_SESSION: [ + 'Check the network connection between station and CSMS', + 'Verify the station has not lost power', + 'Review the WebSocket connection stability', + 'Check if the station firmware has a known stability issue', + 'Investigate if maintenance was performed on the station', + ], +}; + +const SEVERITY: Record = { + FAILED_AUTHORIZATION: 'warning', + CONNECTOR_FAULT: 'critical', + STATION_OFFLINE_DURING_SESSION: 'critical', +}; + +// --------------------------------------------------------------------------- +// Payload extraction helpers +// --------------------------------------------------------------------------- + +/** Extract idTagInfo.status from an Authorize CallResult. */ +function getAuthorizeStatus(event: Event): string | null { + if (event.messageType !== 'CallResult') return null; + const payload = event.payload as { idTagInfo?: { status?: unknown } }; + if (typeof payload?.idTagInfo?.status === 'string') { + return payload.idTagInfo.status; + } + return null; +} + +/** Extract status from a StatusNotification Call. */ +function getStatusNotificationStatus(event: Event): string | null { + if (event.messageType !== 'Call' || event.action !== 'StatusNotification') return null; + const payload = event.payload as { status?: unknown }; + if (typeof payload?.status === 'string') { + return payload.status; + } + return null; +} + +/** Extract errorCode from a StatusNotification Call. */ +function getStatusNotificationErrorCode(event: Event): string | null { + if (event.messageType !== 'Call' || event.action !== 'StatusNotification') return null; + const payload = event.payload as { errorCode?: unknown }; + if (typeof payload?.errorCode === 'string') { + return payload.errorCode; + } + return null; +} + +// --------------------------------------------------------------------------- +// Detection rules +// --------------------------------------------------------------------------- + +/** + * Rule 1: FAILED_AUTHORIZATION + * Detects Authorize responses where idTagInfo.status is "Invalid". + */ +function detectFailedAuthorization(events: Event[]): Failure[] { + const failures: Failure[] = []; + + for (const event of events) { + // Look for Authorize CallResult responses + if (event.messageType !== 'CallResult') continue; + + // Check if the matching Call was an Authorize + // We match by messageId — find the Call with the same messageId + const matchingCall = events.find( + (e) => + e.messageType === 'Call' && e.action === 'Authorize' && e.messageId === event.messageId, + ); + + if (!matchingCall) continue; + + const status = getAuthorizeStatus(event); + if (status === 'Invalid') { + failures.push({ + code: 'FAILED_AUTHORIZATION', + description: `Authorization rejected: idTag returned "Invalid" status (messageId: ${event.messageId})`, + severity: SEVERITY.FAILED_AUTHORIZATION, + eventIds: [matchingCall.id, event.id], + suggestedSteps: SUGGESTED_STEPS.FAILED_AUTHORIZATION, + }); + } + } + + return failures; +} + +/** + * Rule 2: CONNECTOR_FAULT + * Detects StatusNotification with status = "Faulted" during an active session. + * A "during active session" means there's a StartTransaction before the fault + * and either no StopTransaction yet, or the fault occurs before the StopTransaction. + */ +function detectConnectorFault(events: Event[]): Failure[] { + const failures: Failure[] = []; + + // Find all StartTransaction Call events + const startTxIndices = events + .filter((e) => e.messageType === 'Call' && e.action === 'StartTransaction') + .map((e) => events.indexOf(e)); + + for (const startIndex of startTxIndices) { + const startEvent = events[startIndex]; + if (!startEvent) continue; + + // Find the corresponding StopTransaction (after this StartTransaction) + let stopIndex = -1; + for (let i = startIndex + 1; i < events.length; i++) { + const ev = events[i]; + if (ev && ev.messageType === 'Call' && ev.action === 'StopTransaction') { + stopIndex = i; + break; + } + } + + // Look for Faulted StatusNotification between StartTransaction and StopTransaction + // (or until the end of events if no StopTransaction) + const searchEnd = stopIndex > -1 ? stopIndex : events.length; + + for (let i = startIndex; i < searchEnd; i++) { + const event = events[i]; + if (!event) continue; + + const status = getStatusNotificationStatus(event); + if (status === 'Faulted') { + const errorCode = getStatusNotificationErrorCode(event); + failures.push({ + code: 'CONNECTOR_FAULT', + description: `Connector fault detected during active session: status "Faulted"${errorCode ? `, errorCode "${errorCode}"` : ''} (messageId: ${event.messageId})`, + severity: SEVERITY.CONNECTOR_FAULT, + eventIds: [startEvent.id, event.id], + suggestedSteps: SUGGESTED_STEPS.CONNECTOR_FAULT, + }); + break; // Only report one fault per session + } + } + } + + return failures; +} + +/** + * Rule 3: STATION_OFFLINE_DURING_SESSION + * Detects sessions where: + * - There's a StartTransaction but no StopTransaction (session never completed) + * - OR the connector transitions to Unavailable/Offline during an active transaction + */ +function detectStationOfflineDuringSession(_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) { + // Session never completed — station went offline or stopped communicating + failures.push({ + code: 'STATION_OFFLINE_DURING_SESSION', + description: `Session ${session.sessionId} (transaction ${session.transactionId}) has a StartTransaction but no StopTransaction — station may have gone offline during an active session`, + severity: SEVERITY.STATION_OFFLINE_DURING_SESSION, + eventIds: session.events + .filter((e) => e.messageType === 'Call' && e.action === 'StartTransaction') + .map((e) => e.id), + suggestedSteps: SUGGESTED_STEPS.STATION_OFFLINE_DURING_SESSION, + }); + continue; + } + + // Check for Unavailable/Offline status during the session + if (hasStart && hasStop) { + const startIndex = session.events.findIndex( + (e) => e.messageType === 'Call' && e.action === 'StartTransaction', + ); + const stopIndex = session.events.findIndex( + (e) => e.messageType === 'Call' && e.action === 'StopTransaction', + ); + + for (let i = startIndex; i <= stopIndex; i++) { + const event = session.events[i]; + if (!event) continue; + const status = getStatusNotificationStatus(event); + if (status === 'Unavailable' || status === 'Offline') { + failures.push({ + code: 'STATION_OFFLINE_DURING_SESSION', + description: `Station reported "${status}" status during active session ${session.sessionId} (transaction ${session.transactionId})`, + severity: SEVERITY.STATION_OFFLINE_DURING_SESSION, + eventIds: [event.id], + suggestedSteps: SUGGESTED_STEPS.STATION_OFFLINE_DURING_SESSION, + }); + break; + } + } + } + } + + return failures; +} + +// --------------------------------------------------------------------------- +// detectFailures() +// --------------------------------------------------------------------------- + +/** + * Detect failures in a trace by analyzing events and sessions. + * + * @param events - All normalized events from the trace + * @param sessions - Sessions derived from the events + * @returns Array of detected failures + * + * @see ADR-0003 + */ +export function detectFailures(events: Event[], sessions: Session[]): Failure[] { + const failures: Failure[] = []; + + // Rule 1: Failed authorization + 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)); + + return failures; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5ce8699..89b890f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,5 +25,17 @@ export { extractErrorDescription, } from './normalizer.js'; +// Timeline +export { buildSessionTimeline } from './timeline.js'; + +// Detection +export { detectFailures } from './detection.js'; + +// Summarizer +export { summarizeSession, summarizeSessions } from './summarizer.js'; + +// Validator +export { validateMessage, validateMessages } from './validator.js'; + // Fixtures export * from './fixtures/index.js'; diff --git a/packages/core/src/summarizer.test.ts b/packages/core/src/summarizer.test.ts new file mode 100644 index 0000000..99bda63 --- /dev/null +++ b/packages/core/src/summarizer.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { summarizeSession, summarizeSessions } from './summarizer.js'; +import { buildSessionTimeline } from './timeline.js'; +import { parseTrace } from './parser.js'; +import { detectFailures } from './detection.js'; +import type { Session, Failure } from './types.js'; + +describe('summarizeSession', () => { + it('produces a summary with correct stats', async () => { + const { normalSession } = await import('./fixtures/index.js'); + const result = parseTrace(JSON.stringify(normalSession)); + const sessions = buildSessionTimeline(result.events); + const summary = summarizeSession(sessions[0] as Session, 0); + + expect(summary.sessionId).toBe('session-0'); + expect(summary.stationId).toBe('CS-SYNTHETIC-001'); + expect(summary.connectorId).toBe(1); + expect(summary.transactionId).toBe(100001); + expect(summary.status).toBe('completed'); + expect(summary.eventCount).toBeGreaterThan(0); + expect(summary.durationMs).toBeGreaterThan(0); + expect(summary.failureCount).toBe(0); + expect(summary.actionSequence).toContain('BootNotification'); + expect(summary.actionSequence).toContain('StartTransaction'); + expect(summary.actionSequence).toContain('StopTransaction'); + }); + + it('handles null duration when timestamps are missing', () => { + const session: Session = { + sessionId: 'test', + stationId: 'CS-001', + connectorId: 1, + transactionId: 100, + startTime: null, + endTime: null, + events: [], + status: 'active', + }; + const summary = summarizeSession(session); + expect(summary.durationMs).toBeNull(); + }); + + it('counts actions in sequence', async () => { + const { normalSession } = await import('./fixtures/index.js'); + const result = parseTrace(JSON.stringify(normalSession)); + const sessions = buildSessionTimeline(result.events); + const summary = summarizeSession(sessions[0] as Session, 0); + + // Should include all Call actions in order + expect(summary.actionSequence[0]).toBe('BootNotification'); + expect(summary.actionSequence).toContain('Authorize'); + expect(summary.actionSequence).toContain('MeterValues'); + }); +}); + +describe('summarizeSessions', () => { + it('summarizes all sessions with failure counts', async () => { + const { failedAuth } = await import('./fixtures/index.js'); + const result = parseTrace(JSON.stringify(failedAuth)); + const sessions = buildSessionTimeline(result.events); + const failures = detectFailures(result.events, sessions); + const summaries = summarizeSessions(sessions, failures as Failure[]); + + expect(summaries.length).toBe(sessions.length); + // failed-auth fixture should have failures + const totalFailures = summaries.reduce((sum, s) => sum + s.failureCount, 0); + expect(totalFailures).toBeGreaterThan(0); + }); + + it('returns empty array for no sessions', () => { + const summaries = summarizeSessions([], []); + expect(summaries).toHaveLength(0); + }); +}); diff --git a/packages/core/src/summarizer.ts b/packages/core/src/summarizer.ts new file mode 100644 index 0000000..010326c --- /dev/null +++ b/packages/core/src/summarizer.ts @@ -0,0 +1,59 @@ +/** + * Session summarizer — produces overview statistics for a charging session. + * + * @see ADR-0003 + */ + +import type { Session, SessionSummary } from './types.js'; + +/** + * Summarize a charging session with overview statistics. + * + * @param session - The session to summarize + * @param failureCount - Number of failures detected in this session + * @returns Summary statistics + */ +export function summarizeSession(session: Session, failureCount = 0): SessionSummary { + const actionSequence = session.events + .filter((e) => e.messageType === 'Call' && e.action !== null) + .map((e) => e.action as string); + + const durationMs = + session.startTime !== null && session.endTime !== null + ? session.endTime - session.startTime + : null; + + return { + sessionId: session.sessionId, + stationId: session.stationId, + connectorId: session.connectorId, + transactionId: session.transactionId, + status: session.status, + eventCount: session.events.length, + durationMs, + failureCount, + actionSequence, + }; +} + +/** + * Summarize all sessions in a trace. + * + * @param sessions - All sessions in the trace + * @param failures - All detected failures + * @returns Array of session summaries + */ +export function summarizeSessions( + sessions: Session[], + failures: { eventIds: string[] }[], +): SessionSummary[] { + return sessions.map((session) => { + // Count failures that belong to this session + const sessionEventIds = new Set(session.events.map((e) => e.id)); + const failureCount = failures.filter((f) => + f.eventIds.some((id) => sessionEventIds.has(id)), + ).length; + + return summarizeSession(session, failureCount); + }); +} diff --git a/packages/core/src/timeline.test.ts b/packages/core/src/timeline.test.ts new file mode 100644 index 0000000..5820d62 --- /dev/null +++ b/packages/core/src/timeline.test.ts @@ -0,0 +1,410 @@ +import { describe, it, expect } from 'vitest'; +import { buildSessionTimeline } from './timeline.js'; +import type { Event, RawOcppMessage } from './types.js'; + +// Helpers +function makeEvent( + id: string, + messageId: string, + messageType: 'Call' | 'CallResult' | 'CallError', + action: string | null, + payload: unknown = {}, + timestamp: number | null = null, + direction: 'CS_TO_CSMS' | 'CSMS_TO_CS' | 'UNKNOWN' = 'CS_TO_CSMS', +): Event { + let rawMessage: RawOcppMessage; + if (messageType === 'Call') { + rawMessage = [2, messageId, action as string, payload]; + } else if (messageType === 'CallResult') { + rawMessage = [3, messageId, payload]; + } else { + rawMessage = [4, messageId, 'Error', 'desc', payload]; + } + return { + id, + messageId, + timestamp, + direction, + messageType, + action, + payload, + errorCode: messageType === 'CallError' ? 'Error' : null, + errorDescription: messageType === 'CallError' ? 'desc' : null, + rawMessage, + }; +} + +// A normal session: boot, authorize, start tx, meter values, stop tx +function makeNormalSessionEvents(): Event[] { + return [ + makeEvent( + 'evt-0001', + 'msg-001', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'evt-0002', + 'msg-001', + 'CallResult', + null, + { status: 'Accepted' }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0003', + 'msg-002', + 'Call', + 'StatusNotification', + { connectorId: 0, status: 'Available' }, + 2000, + ), + makeEvent('evt-0004', 'msg-002', 'CallResult', null, {}, 2500, 'CSMS_TO_CS'), + makeEvent('evt-0005', 'msg-003', 'Call', 'Authorize', { idTag: 'TAG-001' }, 3000), + makeEvent( + 'evt-0006', + 'msg-003', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' } }, + 3500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0007', + 'msg-004', + 'Call', + 'StartTransaction', + { connectorId: 1, idTag: 'TAG-001', meterStart: 0 }, + 4000, + ), + makeEvent( + 'evt-0008', + 'msg-004', + 'CallResult', + null, + { transactionId: 100001, idTagInfo: { status: 'Accepted' } }, + 4500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0009', + 'msg-005', + 'Call', + 'StatusNotification', + { connectorId: 1, status: 'Charging' }, + 5000, + ), + makeEvent('evt-0010', 'msg-005', 'CallResult', null, {}, 5500, 'CSMS_TO_CS'), + makeEvent( + 'evt-0011', + 'msg-006', + 'Call', + 'MeterValues', + { connectorId: 1, transactionId: 100001, meterValue: [] }, + 6000, + ), + makeEvent('evt-0012', 'msg-006', 'CallResult', null, {}, 6500, 'CSMS_TO_CS'), + makeEvent( + 'evt-0013', + 'msg-007', + 'Call', + 'StopTransaction', + { transactionId: 100001, meterStop: 10000, reason: 'EVDisconnected' }, + 7000, + ), + makeEvent( + 'evt-0014', + 'msg-007', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' } }, + 7500, + 'CSMS_TO_CS', + ), + ]; +} + +describe('buildSessionTimeline', () => { + it('creates a single session for a normal charging session', () => { + const events = makeNormalSessionEvents(); + const sessions = buildSessionTimeline(events); + expect(sessions).toHaveLength(1); + expect(sessions[0]?.transactionId).toBe(100001); + expect(sessions[0]?.status).toBe('completed'); + }); + + it('sets session start and end times', () => { + const events = makeNormalSessionEvents(); + const sessions = buildSessionTimeline(events); + expect(sessions[0]?.startTime).toBe(1000); + expect(sessions[0]?.endTime).toBe(7500); + }); + + it('extracts connectorId from StartTransaction', () => { + const events = makeNormalSessionEvents(); + const sessions = buildSessionTimeline(events); + expect(sessions[0]?.connectorId).toBe(1); + }); + + it('extracts stationId from BootNotification', () => { + const events = makeNormalSessionEvents(); + const sessions = buildSessionTimeline(events); + expect(sessions[0]?.stationId).toBe('CS-001'); + }); + + it('returns "unknown" stationId when no BootNotification', () => { + const events = [ + makeEvent('evt-0001', 'msg-001', 'Call', 'Authorize', { idTag: 'TAG-001' }, 1000), + ]; + const sessions = buildSessionTimeline(events); + expect(sessions[0]?.stationId).toBe('unknown'); + }); + + it('sets status to "active" when session has no StopTransaction', () => { + const events = [ + makeEvent( + 'evt-0001', + 'msg-001', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'evt-0002', + 'msg-001', + 'CallResult', + null, + { status: 'Accepted' }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0003', + 'msg-002', + 'Call', + 'StartTransaction', + { connectorId: 1, idTag: 'TAG-001', meterStart: 0 }, + 2000, + ), + makeEvent( + 'evt-0004', + 'msg-002', + 'CallResult', + null, + { transactionId: 200001, idTagInfo: { status: 'Accepted' } }, + 2500, + 'CSMS_TO_CS', + ), + ]; + const sessions = buildSessionTimeline(events); + expect(sessions[0]?.status).toBe('active'); + }); + + it('sets status to "aborted" when connector faults during session', () => { + const events = [ + makeEvent( + 'evt-0001', + 'msg-001', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'evt-0002', + 'msg-001', + 'CallResult', + null, + { status: 'Accepted' }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0003', + 'msg-002', + 'Call', + 'StartTransaction', + { connectorId: 1, idTag: 'TAG-001', meterStart: 0 }, + 2000, + ), + makeEvent( + 'evt-0004', + 'msg-002', + 'CallResult', + null, + { transactionId: 300001, idTagInfo: { status: 'Accepted' } }, + 2500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0005', + 'msg-003', + 'Call', + 'StatusNotification', + { connectorId: 1, status: 'Faulted', errorCode: 'ConnectorLockFailure' }, + 3000, + ), + makeEvent('evt-0006', 'msg-003', 'CallResult', null, {}, 3500, 'CSMS_TO_CS'), + makeEvent( + 'evt-0007', + 'msg-004', + 'Call', + 'StopTransaction', + { transactionId: 300001, meterStop: 5000, reason: 'Faulted' }, + 4000, + ), + makeEvent( + 'evt-0008', + 'msg-004', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' } }, + 4500, + 'CSMS_TO_CS', + ), + ]; + const sessions = buildSessionTimeline(events); + expect(sessions[0]?.status).toBe('completed'); // has StopTransaction + }); + + it('handles events with no transactions', () => { + const events = [ + makeEvent( + 'evt-0001', + 'msg-001', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'evt-0002', + 'msg-001', + 'CallResult', + null, + { status: 'Accepted' }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent('evt-0003', 'msg-002', 'Call', 'Heartbeat', {}, 2000), + makeEvent( + 'evt-0004', + 'msg-002', + 'CallResult', + null, + { currentTime: '2024-01-15T10:00:00.000Z' }, + 2500, + 'CSMS_TO_CS', + ), + ]; + const sessions = buildSessionTimeline(events); + expect(sessions).toHaveLength(1); + expect(sessions[0]?.transactionId).toBeNull(); + }); + + it('handles empty events array', () => { + const sessions = buildSessionTimeline([]); + expect(sessions).toHaveLength(0); + }); + + it('handles multiple sessions', () => { + const events = [ + // First session + makeEvent( + 'evt-0001', + 'msg-001', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'evt-0002', + 'msg-001', + 'CallResult', + null, + { status: 'Accepted' }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0003', + 'msg-002', + 'Call', + 'StartTransaction', + { connectorId: 1, idTag: 'TAG-001', meterStart: 0 }, + 2000, + ), + makeEvent( + 'evt-0004', + 'msg-002', + 'CallResult', + null, + { transactionId: 100001, idTagInfo: { status: 'Accepted' } }, + 2500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0005', + 'msg-003', + 'Call', + 'StopTransaction', + { transactionId: 100001, meterStop: 5000, reason: 'EVDisconnected' }, + 3000, + ), + makeEvent( + 'evt-0006', + 'msg-003', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' } }, + 3500, + 'CSMS_TO_CS', + ), + // Second session + makeEvent( + 'evt-0007', + 'msg-004', + 'Call', + 'StartTransaction', + { connectorId: 2, idTag: 'TAG-002', meterStart: 0 }, + 4000, + ), + makeEvent( + 'evt-0008', + 'msg-004', + 'CallResult', + null, + { transactionId: 100002, idTagInfo: { status: 'Accepted' } }, + 4500, + 'CSMS_TO_CS', + ), + makeEvent( + 'evt-0009', + 'msg-005', + 'Call', + 'StopTransaction', + { transactionId: 100002, meterStop: 3000, reason: 'EVDisconnected' }, + 5000, + ), + makeEvent( + 'evt-0010', + 'msg-005', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' } }, + 5500, + 'CSMS_TO_CS', + ), + ]; + const sessions = buildSessionTimeline(events); + expect(sessions).toHaveLength(2); + expect(sessions[0]?.transactionId).toBe(100001); + expect(sessions[1]?.transactionId).toBe(100002); + }); +}); diff --git a/packages/core/src/timeline.ts b/packages/core/src/timeline.ts new file mode 100644 index 0000000..5516c25 --- /dev/null +++ b/packages/core/src/timeline.ts @@ -0,0 +1,376 @@ +/** + * Session timeline builder — correlates events into logical charging sessions. + * + * Session correlation strategy (ADR-0006): + * 1. Primary: transactionId (from StartTransaction response / StopTransaction) + * 2. Secondary: connectorId + stationId grouping + * 3. Events without a transaction are grouped by stationId + connectorId + * + * @see ADR-0006 + */ + +import type { Event, Session } from './types.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Extract transactionId from an event's payload. + * - StartTransaction response (CallResult): { transactionId, idTagInfo } + * - StopTransaction request (Call): { transactionId, ... } + * - MeterValues request (Call): { transactionId, ... } + */ +function extractTransactionId(event: Event): number | null { + if (event.messageType === 'Call') { + if (event.action === 'StartTransaction') { + // StartTransaction request doesn't have transactionId — the response does. + return null; + } + if (event.action === 'StopTransaction' || event.action === 'MeterValues') { + const payload = event.payload as { transactionId?: unknown }; + if (typeof payload?.transactionId === 'number') { + return payload.transactionId; + } + } + } + + if (event.messageType === 'CallResult') { + const payload = event.payload as { transactionId?: unknown }; + if (typeof payload?.transactionId === 'number') { + return payload.transactionId; + } + } + + return null; +} + +/** + * Extract connectorId from an event's payload. + * Present in StatusNotification, StartTransaction, MeterValues. + */ +function extractConnectorId(event: Event): number | null { + if (event.messageType !== 'Call' || event.payload === null) { + return null; + } + const payload = event.payload as { connectorId?: unknown }; + if (typeof payload?.connectorId === 'number') { + return payload.connectorId; + } + return null; +} + +/** + * Extract stationId from BootNotification payload. + */ +function extractStationId(events: Event[]): string { + for (const event of events) { + if (event.messageType === 'Call' && event.action === 'BootNotification') { + const payload = event.payload as { chargePointSerialNumber?: unknown }; + if (typeof payload?.chargePointSerialNumber === 'string') { + return payload.chargePointSerialNumber; + } + } + } + return 'unknown'; +} + +/** + * Determine if an event indicates a connector fault status. + */ +function isFaultedStatus(event: Event): boolean { + if (event.messageType !== 'Call' || event.action !== 'StatusNotification') { + return false; + } + const payload = event.payload as { status?: unknown }; + return payload?.status === 'Faulted'; +} + +/** + * Determine if an event indicates the connector is unavailable/offline. + */ +function isUnavailableStatus(event: Event): boolean { + if (event.messageType !== 'Call' || event.action !== 'StatusNotification') { + return false; + } + const payload = event.payload as { status?: unknown }; + return payload?.status === 'Unavailable' || payload?.status === 'Offline'; +} + +// --------------------------------------------------------------------------- +// buildSessionTimeline() +// --------------------------------------------------------------------------- + +/** + * Build session timelines from normalized events. + * + * Correlates events into logical charging sessions by: + * 1. Matching StartTransaction responses to find transactionIds + * 2. Grouping StopTransaction and MeterValues events by transactionId + * 3. Grouping StatusNotification events by connectorId + stationId + * 4. Events without a transaction are assigned to a "no-session" group + * + * Sessions are ordered by their first event's timestamp. + * + * @see ADR-0006 + */ +export function buildSessionTimeline(events: Event[]): Session[] { + if (events.length === 0) { + return []; + } + + const stationId = extractStationId(events); + + // Step 1: Build a map of messageId → transactionId from StartTransaction responses. + // The StartTransaction Call (msg-005) gets a response (CallResult) with transactionId. + // We match by messageId. + const messageIdToTransactionId = new Map(); + + for (const event of events) { + if (event.messageType === 'CallResult') { + const txId = extractTransactionId(event); + if (txId !== null) { + messageIdToTransactionId.set(event.messageId, txId); + } + } + } + + // Step 2: Build a map of messageId → transactionId for StopTransaction/MeterValues Calls. + // These Calls contain the transactionId in their payload directly. + const callMessageIdToTransactionId = new Map(); + for (const event of events) { + if (event.messageType === 'Call') { + const txId = extractTransactionId(event); + if (txId !== null) { + callMessageIdToTransactionId.set(event.messageId, txId); + } + } + } + + // Step 3: Group events into sessions. + // A session is identified by a transactionId. + // Events are assigned to a session if: + // - They contain that transactionId in their payload (StopTransaction, MeterValues) + // - They are a StartTransaction Call/CallResult with a matched messageId + // - They are StatusNotification events for the same connectorId around the same time + + // First, find all StartTransaction Call messages and their corresponding response transactionIds + const startTxCalls: { + callEvent: Event; + responseEvent: Event | null; + transactionId: number | null; + }[] = []; + + for (const event of events) { + if (event.messageType === 'Call' && event.action === 'StartTransaction') { + const txId = messageIdToTransactionId.get(event.messageId) ?? null; + const responseEvent = + events.find((e) => e.messageId === event.messageId && e.messageType === 'CallResult') ?? + null; + startTxCalls.push({ callEvent: event, responseEvent, transactionId: txId }); + } + } + + // If no transactions found, return a single session with all events + if (startTxCalls.length === 0) { + return [createSession('session-0', stationId, events)]; + } + + // Build a map of messageId → transactionId for ALL Call messages that have + // a transactionId (StopTransaction, MeterValues). Their CallResult responses + // inherit this transactionId. + const callMessageIdToTxId = new Map(); + for (const event of events) { + if (event.messageType === 'Call') { + const txId = extractTransactionId(event); + if (txId !== null) { + callMessageIdToTxId.set(event.messageId, txId); + } + // StartTransaction Call — get txId from the response + if (event.action === 'StartTransaction') { + const respTxId = messageIdToTransactionId.get(event.messageId); + if (respTxId !== undefined) { + callMessageIdToTxId.set(event.messageId, respTxId); + } + } + } + } + + // Group events by transactionId + const sessionEventsMap = new Map(); + + for (const event of events) { + let txId: number | null = null; + + if (event.messageType === 'Call') { + // Call: check if this messageId has a known transactionId + txId = callMessageIdToTxId.get(event.messageId) ?? null; + if (txId === null) { + // Try extracting from payload directly + txId = extractTransactionId(event); + } + } else if (event.messageType === 'CallResult') { + // CallResult: check if the matching Call has a transactionId + txId = callMessageIdToTxId.get(event.messageId) ?? null; + // Also check if transactionId is directly in the response payload + if (txId === null) { + txId = extractTransactionId(event); + } + } + + if (txId !== null) { + const group = sessionEventsMap.get(txId) ?? []; + group.push(event); + sessionEventsMap.set(txId, group); + } else { + // Events without a transaction — assign to null group (will be distributed) + const group = sessionEventsMap.get(null) ?? []; + group.push(event); + sessionEventsMap.set(null, group); + } + } + + // Distribute null-group events to sessions by connectorId and time proximity + const nullEvents = sessionEventsMap.get(null) ?? []; + sessionEventsMap.delete(null); + const usedNullEventIds = new Set(); + + // Build sessions + const sessions: Session[] = []; + let sessionIndex = 0; + + for (const [txId, txEvents] of sessionEventsMap) { + // Find connectorId from the StartTransaction Call + const startTxCall = txEvents.find( + (e) => e.messageType === 'Call' && e.action === 'StartTransaction', + ); + const connectorId = startTxCall ? extractConnectorId(startTxCall) : null; + + // Include null-group events that belong to this session's connector + // and time range, or that are responses to Calls already in the session. + const txMessageIds = new Set(txEvents.map((e) => e.messageId)); + const sessionStart = txEvents[0]?.timestamp ?? null; + const sessionEnd = txEvents[txEvents.length - 1]?.timestamp ?? null; + + const relatedNullEvents = nullEvents.filter((e) => { + // Skip already-used events + if (usedNullEventIds.has(e.id)) return false; + + // Include CallResult/CallError that match a Call already in the session + if (e.messageType !== 'Call' && txMessageIds.has(e.messageId)) { + return true; + } + + const eventConnectorId = extractConnectorId(e); + // Include events with matching connectorId within time range + if (eventConnectorId !== null && connectorId !== null && eventConnectorId === connectorId) { + if (sessionStart !== null && sessionEnd !== null && e.timestamp !== null) { + return e.timestamp >= sessionStart && e.timestamp <= sessionEnd + 60000; // 1 min grace + } + return true; // No timestamps — include + } + + // Include BootNotification events at the start + if (e.action === 'BootNotification' || e.action === 'Heartbeat') { + // Only include if this is the first session + return sessionIndex === 0; + } + + // Include events without connectorId (like Authorize) within time range + // (extending before session start to include pre-session events like Authorize) + if ( + eventConnectorId === null && + e.messageType === 'Call' && + e.action !== 'BootNotification' && + e.action !== 'Heartbeat' + ) { + if (sessionStart !== null && sessionEnd !== null && e.timestamp !== null) { + // Include if within 5 minutes before session start through 1 min after end + return e.timestamp >= sessionStart - 300000 && e.timestamp <= sessionEnd + 60000; + } + } + + return false; + }); + + // Mark these null events as used so they aren't included in other sessions + for (const e of relatedNullEvents) { + usedNullEventIds.add(e.id); + } + + const allEvents = [...txEvents, ...relatedNullEvents].sort((a, b) => { + // Sort by original event order (ID is sequential) + return a.id.localeCompare(b.id); + }); + + sessions.push( + createSession(`session-${sessionIndex}`, stationId, allEvents, connectorId, txId), + ); + sessionIndex++; + } + + // Sort sessions by start time + sessions.sort((a, b) => { + if (a.startTime === null) return 1; + if (b.startTime === null) return -1; + return a.startTime - b.startTime; + }); + + // Renumber sessions + sessions.forEach((s, i) => { + s.sessionId = `session-${i}`; + }); + + return sessions; +} + +// --------------------------------------------------------------------------- +// createSession helper +// --------------------------------------------------------------------------- + +function createSession( + sessionId: string, + stationId: string, + events: Event[], + connectorId: number | null = null, + transactionId: number | null = null, +): Session { + const timestamps = events.map((e) => e.timestamp).filter((t): t is number => t !== null); + + const startTime = timestamps.length > 0 ? Math.min(...timestamps) : null; + const endTime = timestamps.length > 0 ? Math.max(...timestamps) : null; + + // Determine session status + let status: Session['status'] = 'active'; + const hasStop = events.some((e) => e.messageType === 'Call' && e.action === 'StopTransaction'); + const hasFaulted = events.some(isFaultedStatus); + const hasUnavailable = events.some(isUnavailableStatus); + + if (hasStop) { + status = 'completed'; + } else if (hasFaulted || hasUnavailable) { + status = 'aborted'; + } + + // If we didn't get a transactionId from parameter, try to extract it + if (transactionId === null) { + for (const event of events) { + const txId = extractTransactionId(event); + if (txId !== null) { + transactionId = txId; + break; + } + } + } + + return { + sessionId, + stationId, + connectorId, + transactionId, + startTime, + endTime, + events, + status, + }; +} diff --git a/packages/core/src/validator.test.ts b/packages/core/src/validator.test.ts new file mode 100644 index 0000000..208f0c9 --- /dev/null +++ b/packages/core/src/validator.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from 'vitest'; +import { validateMessage, validateMessages } from './validator.js'; +import type { Event, RawOcppMessage } from './types.js'; + +// Helpers +function makeEvent( + id: string, + messageId: string, + messageType: 'Call' | 'CallResult' | 'CallError', + action: string | null, + payload: unknown = {}, + rawMessageOverride?: RawOcppMessage, +): Event { + let rawMessage: RawOcppMessage; + if (messageType === 'Call') { + rawMessage = rawMessageOverride ?? [2, messageId, action as string, payload]; + } else if (messageType === 'CallResult') { + rawMessage = rawMessageOverride ?? [3, messageId, payload]; + } else { + rawMessage = rawMessageOverride ?? [4, messageId, 'Error', 'desc', payload]; + } + return { + id, + messageId, + timestamp: 1000, + direction: 'CS_TO_CSMS', + messageType, + action, + payload, + errorCode: messageType === 'CallError' ? 'Error' : null, + errorDescription: messageType === 'CallError' ? 'desc' : null, + rawMessage, + }; +} + +describe('validateMessage', () => { + it('validates a correct Call message', () => { + const event = makeEvent('evt-0001', 'msg-001', 'Call', 'BootNotification', { vendor: 'Test' }); + const result = validateMessage(event); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('validates a correct CallResult message', () => { + const event = makeEvent('evt-0001', 'msg-001', 'CallResult', null, { status: 'Accepted' }); + const result = validateMessage(event); + expect(result.valid).toBe(true); + }); + + it('validates a correct CallError message', () => { + const event = makeEvent('evt-0001', 'msg-001', 'CallError', null, {}); + const result = validateMessage(event); + expect(result.valid).toBe(true); + }); + + it('detects Call with too few elements', () => { + const event = makeEvent('evt-0001', 'msg-001', 'Call', 'Boot', {}, [2, 'msg-001']); // only 2 elements + const result = validateMessage(event); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('at least 4'))).toBe(true); + }); + + it('detects CallResult with too few elements', () => { + const event = makeEvent('evt-0001', 'msg-001', 'CallResult', null, {}, [3, 'msg-001']); // only 2 elements + const result = validateMessage(event); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('at least 3'))).toBe(true); + }); + + it('detects CallError with too few elements', () => { + const event = makeEvent('evt-0001', 'msg-001', 'CallError', null, {}, [4, 'msg-001']); // only 2 elements + const result = validateMessage(event); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('at least 5'))).toBe(true); + }); + + it('detects empty UniqueId', () => { + const event = makeEvent('evt-0001', '', 'Call', 'BootNotification', {}, [ + 2, + '', + 'BootNotification', + {}, + ]); + const result = validateMessage(event); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('UniqueId'))).toBe(true); + }); + + it('detects messageType inconsistency', () => { + // Event says Call but rawMessage has MessageTypeId 3 + const event = makeEvent('evt-0001', 'msg-001', 'Call', 'BootNotification', {}, [ + 3, + 'msg-001', + { status: 'Accepted' }, + ]); + const result = validateMessage(event); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('MessageType mismatch'))).toBe(true); + }); + + it('detects Call with non-string Action', () => { + const event = makeEvent('evt-0001', 'msg-001', 'Call', 'BootNotification', {}, [ + 2, + 'msg-001', + 123, + {}, + ]); + const result = validateMessage(event); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('Action'))).toBe(true); + }); + + it('detects CallError with non-string ErrorCode', () => { + const event = makeEvent('evt-0001', 'msg-001', 'CallError', null, {}, [ + 4, + 'msg-001', + 123, + 'desc', + {}, + ]); + const result = validateMessage(event); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('ErrorCode'))).toBe(true); + }); +}); + +describe('validateMessage with response matching', () => { + it('detects CallResult without matching Call', () => { + const callResult = makeEvent('evt-0001', 'msg-001', 'CallResult', null, { status: 'Accepted' }); + const result = validateMessage(callResult, [callResult]); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('no matching Call'))).toBe(true); + }); + + it('passes when CallResult has matching Call', () => { + const call = makeEvent('evt-0001', 'msg-001', 'Call', 'BootNotification', {}); + const callResult = makeEvent('evt-0002', 'msg-001', 'CallResult', null, { status: 'Accepted' }); + const allEvents = [call, callResult]; + const result = validateMessage(callResult, allEvents); + expect(result.valid).toBe(true); + }); +}); + +describe('validateMessages', () => { + it('validates all events and returns a map', () => { + const events = [ + makeEvent('evt-0001', 'msg-001', 'Call', 'BootNotification', {}), + makeEvent('evt-0002', 'msg-001', 'CallResult', null, { status: 'Accepted' }), + ]; + const results = validateMessages(events); + expect(results.size).toBe(2); + expect(results.get('evt-0001')?.valid).toBe(true); + expect(results.get('evt-0002')?.valid).toBe(true); + }); + + it('returns empty map for empty events', () => { + const results = validateMessages([]); + expect(results.size).toBe(0); + }); +}); diff --git a/packages/core/src/validator.ts b/packages/core/src/validator.ts new file mode 100644 index 0000000..b3f42fa --- /dev/null +++ b/packages/core/src/validator.ts @@ -0,0 +1,150 @@ +/** + * OCPP message validator — checks structural compliance of individual messages. + * + * Validates that an OCPP 1.6 JSON message conforms to the protocol's + * structural requirements. + * + * @see docs/trace-format-spec.md + */ + +import type { Event, MessageType, ValidationResult } from './types.js'; + +// --------------------------------------------------------------------------- +// Validation rules +// --------------------------------------------------------------------------- + +/** + * Validate the message structure based on its type. + * + * Call (2): [2, UniqueId, Action, Payload] — 4+ elements, Action is string + * CallResult (3): [3, UniqueId, Payload] — 3+ elements + * CallError (4): [4, UniqueId, ErrorCode, ErrorDescription, ErrorDetails] — 5+ elements + */ +function validateMessageStructure(event: Event): string[] { + const errors: string[] = []; + const msg = event.rawMessage; + const msgType = event.messageType; + + // Check minimum length + const minLength = msgType === 'Call' ? 4 : msgType === 'CallResult' ? 3 : 5; + if (msg.length < minLength) { + errors.push(`${msgType} message must have at least ${minLength} elements (has ${msg.length})`); + } + + // Check MessageTypeId (index 0) + if (msg[0] !== 2 && msg[0] !== 3 && msg[0] !== 4) { + errors.push(`Invalid MessageTypeId: ${msg[0]} (expected 2, 3, or 4)`); + } + + // Check UniqueId (index 1) + if (typeof msg[1] !== 'string' || msg[1] === '') { + errors.push('UniqueId (index 1) must be a non-empty string'); + } + + // Call-specific: Action (index 2) must be a string + if (msgType === 'Call') { + if (typeof msg[2] !== 'string' || msg[2] === '') { + errors.push('Call Action (index 2) must be a non-empty string'); + } + } + + // CallError-specific: ErrorCode and ErrorDescription + if (msgType === 'CallError') { + if (typeof msg[2] !== 'string' || msg[2] === '') { + errors.push('CallError ErrorCode (index 2) must be a non-empty string'); + } + if (typeof msg[3] !== 'string') { + errors.push('CallError ErrorDescription (index 3) must be a string'); + } + } + + return errors; +} + +/** + * Validate that the event's messageType matches the raw message's MessageTypeId. + */ +function validateTypeConsistency(event: Event): string[] { + const errors: string[] = []; + const expectedType: MessageType = + event.rawMessage[0] === 2 ? 'Call' : event.rawMessage[0] === 3 ? 'CallResult' : 'CallError'; + + if (event.messageType !== expectedType) { + errors.push( + `MessageType mismatch: event says "${event.messageType}" but raw message has MessageTypeId ${event.rawMessage[0]} ("${expectedType}")`, + ); + } + + return errors; +} + +/** + * Validate that CallResult/CallError have a matching Call. + * This is a soft validation — the function checks if there's a Call with + * the same messageId in the provided event list. + */ +function validateResponseHasCall(event: Event, allEvents: Event[]): string[] { + const errors: string[] = []; + + if (event.messageType === 'CallResult' || event.messageType === 'CallError') { + const hasCall = allEvents.some( + (e) => e.messageType === 'Call' && e.messageId === event.messageId, + ); + if (!hasCall) { + errors.push(`${event.messageType} with messageId "${event.messageId}" has no matching Call`); + } + } + + return errors; +} + +// --------------------------------------------------------------------------- +// validateMessage() +// --------------------------------------------------------------------------- + +/** + * Validate a single OCPP message for structural compliance. + * + * Checks: + * - Message array has correct minimum length for its type + * - MessageTypeId is 2, 3, or 4 + * - UniqueId is a non-empty string + * - Call messages have a string Action + * - CallError messages have ErrorCode and ErrorDescription + * - MessageType field is consistent with raw message's MessageTypeId + * + * @param event - The event to validate + * @param allEvents - Optional: all events in the trace (for Call/Response matching) + * @returns Validation result with errors array + */ +export function validateMessage(event: Event, allEvents?: Event[]): ValidationResult { + const errors: string[] = []; + + errors.push(...validateMessageStructure(event)); + errors.push(...validateTypeConsistency(event)); + + if (allEvents) { + errors.push(...validateResponseHasCall(event, allEvents)); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * Validate all events in a trace. + * + * @param events - All events in the trace + * @returns Map of event ID to validation result + */ +export function validateMessages(events: Event[]): Map { + const results = new Map(); + + for (const event of events) { + results.set(event.id, validateMessage(event, events)); + } + + return results; +}