Skip to content

Commit 63ff06f

Browse files
committed
Add embedded DBC support and enum labels
Persist and use embedded DBC data for replay import/export and improve plotting for enumerated signals.
1 parent a510402 commit 63ff06f

6 files changed

Lines changed: 92 additions & 13 deletions

File tree

pecan/src/components/PlotManager.tsx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Plotly from "plotly.js-dist-min";
33
import { dataStore } from "../lib/DataStore";
44
import { createGrafanaDashboard } from "../services/GrafanaService";
55
import { useTimeline } from "../context/TimelineContext";
6+
import { getValueDefs } from "../utils/canProcessor";
67

78
// Standard Nivo colors (or similar palette) to ensure consistency between plot and list
89
const PLOT_COLORS = [
@@ -207,20 +208,45 @@ function PlotManager({
207208
}
208209

209210
if (xData.length > 0) {
211+
const valueDefs = getValueDefs(signal.signalName);
212+
const traceExtras = valueDefs
213+
? {
214+
text: yData.map((v) => valueDefs[v] ?? String(v)),
215+
hovertemplate: `%{text}<br>t=%{x:.1f}s<extra>${signal.messageName} - ${signal.signalName}</extra>`,
216+
}
217+
: {};
218+
210219
traces.push({
211220
x: xData,
212221
y: yData,
213-
type: "scatter", // Can switch to scattergl for performance if needed
222+
type: "scatter",
214223
mode: "lines",
215224
name: `${signal.messageName} - ${signal.signalName}`,
216-
line: {
225+
line: {
217226
width: 2,
218-
color: PLOT_COLORS[index % PLOT_COLORS.length] // Sync color
227+
color: PLOT_COLORS[index % PLOT_COLORS.length],
219228
},
229+
...traceExtras,
220230
});
221231
}
222232
});
223233

234+
// Build enum tick labels if there's a single signal with VAL_ definitions
235+
let yaxisEnumConfig = {};
236+
if (signals.length === 1) {
237+
const valueDefs = getValueDefs(signals[0].signalName);
238+
if (valueDefs) {
239+
const entries = Object.entries(valueDefs).map(([k, v]) => [parseInt(k), v] as [number, string]);
240+
entries.sort((a, b) => a[0] - b[0]);
241+
yaxisEnumConfig = {
242+
tickvals: entries.map(([k]) => k),
243+
ticktext: entries.map(([, v]) => v),
244+
tickmode: "array",
245+
range: [entries[0][0] - 0.5, entries[entries.length - 1][0] + 0.5],
246+
};
247+
}
248+
}
249+
224250
if (traces.length > 0 && plotRef.current) {
225251
// eslint-disable-next-line @typescript-eslint/no-explicit-any
226252
const updatedLayout: any = {
@@ -231,7 +257,8 @@ function PlotManager({
231257
},
232258
yaxis: {
233259
title: "Value",
234-
autorange: true,
260+
autorange: !Object.keys(yaxisEnumConfig).length,
261+
...yaxisEnumConfig,
235262
},
236263
margin: { t: 40, r: 20, b: 40, l: 60 },
237264
paper_bgcolor: paperBg,

pecan/src/components/TimelineBar.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { coldStore } from "../lib/ColdStore";
55
import type { ReplayFrame, ReplayPlotLayout } from "../types/replay";
66
import { parseReplayFile, REPLAY_FRAME_HARD_CAP } from "../utils/replayParser";
77
import { serializePecanV2 } from "../utils/pecanSerializer";
8+
import { getActiveDbcText, usingCachedDBC } from "../utils/canProcessor";
89
import ReplayImportClipModal from "./ReplayImportClipModal";
910

1011
interface TimelineBarProps {
@@ -142,6 +143,7 @@ function TimelineBar({ plotLayouts = [] }: TimelineBarProps) {
142143
fileName: string;
143144
timelineMeta?: Parameters<typeof loadReplayFrames>[2];
144145
plotsMeta?: Parameters<typeof loadReplayFrames>[3];
146+
decodeMeta?: Parameters<typeof loadReplayFrames>[4];
145147
} | null>(null);
146148
const replayFileInputRef = useRef<HTMLInputElement | null>(null);
147149
const showColdStoreSupportHint = source === "live" && !dataStore.isColdStoreSupported();
@@ -334,9 +336,15 @@ function TimelineBar({ plotLayouts = [] }: TimelineBarProps) {
334336
);
335337
}
336338

339+
const dbcText = getActiveDbcText();
340+
const selectedFile = localStorage.getItem('dbc-selected-file') ?? undefined;
337341
const blob = new Blob([serializePecanV2({
338342
frames,
339343
epochBaseMs,
344+
decode: usingCachedDBC() ? {
345+
dbcName: selectedFile,
346+
dbcEmbedded: { format: "dbc", encoding: "utf-8", content: dbcText },
347+
} : undefined,
340348
timeline: {
341349
windowMs,
342350
lastCursorMs: Math.max(0, sliderValue - rangeStart),
@@ -383,13 +391,15 @@ function TimelineBar({ plotLayouts = [] }: TimelineBarProps) {
383391
fileName: file.name,
384392
timelineMeta: parseResult.sessionMeta?.timeline,
385393
plotsMeta: parseResult.sessionMeta?.plots,
394+
decodeMeta: parseResult.sessionMeta?.decode,
386395
});
387396
} else {
388397
await loadReplayFrames(
389398
parseResult.frames,
390399
file.name,
391400
parseResult.sessionMeta?.timeline,
392-
parseResult.sessionMeta?.plots
401+
parseResult.sessionMeta?.plots,
402+
parseResult.sessionMeta?.decode
393403
);
394404
}
395405

@@ -480,7 +490,8 @@ function TimelineBar({ plotLayouts = [] }: TimelineBarProps) {
480490
framesToLoad,
481491
pendingClipImport.fileName,
482492
pendingClipImport.timelineMeta,
483-
pendingClipImport.plotsMeta
493+
pendingClipImport.plotsMeta,
494+
pendingClipImport.decodeMeta
484495
);
485496
setPendingClipImport(null);
486497
}}

pecan/src/context/TimelineContext.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import {
99
type ReactNode,
1010
} from "react";
1111
import { dataStore } from "../lib/DataStore";
12-
import type { ReplayFrame, ReplayPlotsMetadata, ReplayTimelineMetadata } from "../types/replay";
13-
import { createCanProcessor } from "../utils/canProcessor";
12+
import type { ReplayDecodeMetadata, ReplayFrame, ReplayPlotsMetadata, ReplayTimelineMetadata } from "../types/replay";
13+
import { createCanProcessor, setActiveDbcText } from "../utils/canProcessor";
1414

1515
export type TimelineMode = "live" | "paused";
1616
export type TimelineSource = "live" | "replay";
@@ -51,7 +51,8 @@ interface TimelineContextValue {
5151
frames: ReplayFrame[],
5252
fileName: string,
5353
timelineMeta?: ReplayTimelineMetadata,
54-
plotsMeta?: ReplayPlotsMetadata
54+
plotsMeta?: ReplayPlotsMetadata,
55+
decodeMeta?: ReplayDecodeMetadata
5556
) => Promise<void>;
5657
clearReplaySession: () => void;
5758
addCheckpoint: (label?: string) => void;
@@ -290,7 +291,8 @@ export function TimelineProvider({ children }: { children: ReactNode }) {
290291
frames: ReplayFrame[],
291292
fileName: string,
292293
timelineMeta?: ReplayTimelineMetadata,
293-
plotsMeta?: ReplayPlotsMetadata
294+
plotsMeta?: ReplayPlotsMetadata,
295+
decodeMeta?: ReplayDecodeMetadata
294296
) => {
295297
if (!Array.isArray(frames) || frames.length === 0) {
296298
return;
@@ -365,6 +367,11 @@ export function TimelineProvider({ children }: { children: ReactNode }) {
365367
? clampTime(normalizedStartEpochMs + timelineMeta.lastCursorMs, retainedStartTimeMs, endTimeMs)
366368
: endTimeMs;
367369

370+
const embeddedDbc = decodeMeta?.dbcEmbedded;
371+
if (embeddedDbc?.format === "dbc" && embeddedDbc.content) {
372+
setActiveDbcText(embeddedDbc.content);
373+
}
374+
368375
const processor = await createCanProcessor().catch((error) => {
369376
console.warn("[Timeline] Failed to initialize CAN decoder for replay import:", error);
370377
return null;

pecan/src/pages/ReplayViewer.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Upload, AlertTriangle, FileJson } from "lucide-react";
33
import TimelineBar from "../components/TimelineBar";
44
import ReplayImportClipModal from "../components/ReplayImportClipModal";
55
import { parseReplayFile, REPLAY_FRAME_HARD_CAP } from "../utils/replayParser";
6-
import type { ReplayFrame, ReplayParseResult, ReplayPlotsMetadata, ReplayTimelineMetadata } from "../types/replay";
6+
import type { ReplayDecodeMetadata, ReplayFrame, ReplayParseResult, ReplayPlotsMetadata, ReplayTimelineMetadata } from "../types/replay";
77
import { useTimeline } from "../context/TimelineContext";
88

99
function ReplayViewer() {
@@ -16,6 +16,7 @@ function ReplayViewer() {
1616
fileName: string;
1717
timelineMeta?: ReplayTimelineMetadata;
1818
plotsMeta?: ReplayPlotsMetadata;
19+
decodeMeta?: ReplayDecodeMetadata;
1920
} | null>(null);
2021

2122
const handleFilePick = async (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -35,13 +36,15 @@ function ReplayViewer() {
3536
fileName: file.name,
3637
timelineMeta: parseResult.sessionMeta?.timeline,
3738
plotsMeta: parseResult.sessionMeta?.plots,
39+
decodeMeta: parseResult.sessionMeta?.decode,
3840
});
3941
} else {
4042
await loadReplayFrames(
4143
parseResult.frames,
4244
file.name,
4345
parseResult.sessionMeta?.timeline,
44-
parseResult.sessionMeta?.plots
46+
parseResult.sessionMeta?.plots,
47+
parseResult.sessionMeta?.decode
4548
);
4649
}
4750
}
@@ -72,7 +75,8 @@ function ReplayViewer() {
7275
framesToLoad,
7376
pendingClipImport.fileName,
7477
pendingClipImport.timelineMeta,
75-
pendingClipImport.plotsMeta
78+
pendingClipImport.plotsMeta,
79+
pendingClipImport.decodeMeta
7680
);
7781
setPendingClipImport(null);
7882
}}

pecan/src/pages/Trace.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Play, Pause, Trash2, HelpCircle } from "lucide-react";
1010
import { useTraceBuffer } from "../lib/useDataStore";
1111
import type { TelemetrySample } from "../lib/DataStore";
1212
import { serializePecanV2 } from "../utils/pecanSerializer";
13+
import { getActiveDbcText, usingCachedDBC } from "../utils/canProcessor";
1314
import TourGuide, { type TourStep } from "../components/TourGuide";
1415
import RaceCarGame from "../components/RaceCarGame";
1516
import TimelineBar from "../components/TimelineBar";
@@ -126,8 +127,14 @@ function exportPecanSession(
126127
): void {
127128
const baseTimestamp = frames[0]?.timestamp ?? Date.now();
128129

130+
const dbcText = getActiveDbcText();
131+
const selectedFile = localStorage.getItem('dbc-selected-file') ?? undefined;
129132
const blob = new Blob([serializePecanV2({
130133
epochBaseMs: baseTimestamp,
134+
decode: usingCachedDBC() ? {
135+
dbcName: selectedFile,
136+
dbcEmbedded: { format: "dbc", encoding: "utf-8", content: dbcText },
137+
} : undefined,
131138
frames: frames.map((frame) => {
132139
const canIdNumeric = parseCanIdToNumber(frame.msgID);
133140
const dataHex = rawDataToHex(frame.rawData);

pecan/src/utils/canProcessor.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,12 +257,35 @@ export function forceCache(force: boolean) {
257257
usingCache = force;
258258
}
259259

260+
/** Return the currently active DBC text. */
261+
export function getActiveDbcText(): string {
262+
return dbcFile;
263+
}
264+
260265
/** Update the active DBC text used by the next createCanProcessor() call. */
261266
export function setActiveDbcText(text: string): void {
262267
dbcFile = text;
263268
usingCache = true;
264269
}
265270

271+
/**
272+
* Parse VAL_ definitions for a signal from the active DBC text.
273+
* Returns a map of { numericValue -> label } or null if no definitions exist.
274+
*/
275+
export function getValueDefs(signalName: string): Record<number, string> | null {
276+
// VAL_ <msgId> <signalName> <val> "<label>" ... ;
277+
const escaped = signalName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
278+
const regex = new RegExp(`VAL_\\s+\\d+\\s+${escaped}\\s+((?:\\d+\\s+"[^"]*"\\s*)+);`);
279+
const match = dbcFile.match(regex);
280+
if (!match) return null;
281+
282+
const defs: Record<number, string> = {};
283+
for (const [, num, label] of match[1].matchAll(/(\d+)\s+"([^"]*)"/g)) {
284+
defs[parseInt(num)] = label;
285+
}
286+
return Object.keys(defs).length > 0 ? defs : null;
287+
}
288+
266289
export async function clearDbcCache() {
267290
// Clear Cache API if available
268291
try {

0 commit comments

Comments
 (0)