can_viewer/
live_capture.rs

1//! Live capture display state.
2//!
3//! Handles signal decoding and statistics for live display.
4//! MDF4 logging happens in the socket thread for lossless capture.
5
6use crate::dto::{CanFrameDto, CaptureStatsDto, LiveCaptureUpdate, StatsHtml};
7use dbc_rs::FastDbc;
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::time::Instant;
10
11/// Maximum recent frames to keep for the frame stream view.
12const MAX_RECENT_FRAMES: usize = 100;
13
14/// Maximum history points to keep for sparklines.
15const MAX_HISTORY_POINTS: usize = 50;
16
17/// Maximum recent errors to keep.
18const MAX_RECENT_ERRORS: usize = 100;
19
20/// Error entry for tracking bus errors.
21struct ErrorEntry {
22    timestamp: f64,
23    channel: String,
24    error_type: String,
25    details: String,
26    count: u64,
27}
28
29/// Internal message monitor entry.
30struct MessageEntry {
31    can_id: u32,
32    message_name: String,
33    data: Vec<u8>,
34    dlc: u8,
35    count: u64,
36    last_update: f64,
37    // For rate calculation
38    last_count: u64,
39    last_rate_time: f64,
40    rate: f64,
41}
42
43/// Signal key: (can_id, signal_index) - avoids string allocation in hot path.
44type SignalKey = (u32, usize);
45
46/// Internal signal monitor entry with history for sparklines.
47struct SignalEntry {
48    signal_name: String,
49    message_name: String,
50    value: f64,
51    unit: String,
52    last_update: f64,
53    // History for sparkline
54    history: VecDeque<f64>,
55    min_value: f64,
56    max_value: f64,
57}
58
59/// Live capture display state - manages data for live display only.
60///
61/// MDF4 logging happens separately in the socket thread.
62/// This struct is NOT thread-safe - owned by a single processor thread.
63pub struct LiveCaptureState {
64    // Display file path (for UI)
65    capture_file: String,
66
67    // Monitors
68    messages: HashMap<u32, MessageEntry>,
69    signals: HashMap<SignalKey, SignalEntry>,
70
71    // Recent frames ring buffer
72    recent_frames: VecDeque<CanFrameDto>,
73
74    // Error tracking
75    errors: HashMap<String, ErrorEntry>,
76    recent_errors: VecDeque<(f64, String, String)>, // (timestamp, type, details)
77    total_error_count: u64,
78
79    // Statistics
80    frame_count: u64,
81    start_time: Instant,
82
83    // FastDbc for O(1) message lookup and zero-allocation decoding
84    fast_dbc: Option<FastDbc>,
85
86    // Pre-allocated decode buffer (sized for max signals in any message)
87    decode_buffer: Vec<f64>,
88
89    // Blacklist of CAN IDs that failed decoding (no match in DBC)
90    decode_blacklist: HashSet<u32>,
91
92    // Rate calculation
93    last_rate_update: Instant,
94    last_frame_count: u64,
95    frame_rate: f64,
96}
97
98impl LiveCaptureState {
99    /// Create new display state.
100    ///
101    /// Takes an optional `FastDbc` for high-performance O(1) message lookup
102    /// and zero-allocation decoding in the hot path.
103    pub fn new(capture_file: String, fast_dbc: Option<FastDbc>) -> Self {
104        // Pre-allocate decode buffer based on max signals in any message
105        let decode_buffer = fast_dbc
106            .as_ref()
107            .map(|f| vec![0.0f64; f.max_signals()])
108            .unwrap_or_default();
109
110        Self {
111            capture_file,
112            messages: HashMap::new(),
113            signals: HashMap::new(),
114            recent_frames: VecDeque::with_capacity(MAX_RECENT_FRAMES),
115            errors: HashMap::new(),
116            recent_errors: VecDeque::with_capacity(MAX_RECENT_ERRORS),
117            total_error_count: 0,
118            frame_count: 0,
119            start_time: Instant::now(),
120            fast_dbc,
121            decode_buffer,
122            decode_blacklist: HashSet::new(),
123            last_rate_update: Instant::now(),
124            last_frame_count: 0,
125            frame_rate: 0.0,
126        }
127    }
128
129    /// Process a CAN error frame.
130    pub fn process_error(
131        &mut self,
132        timestamp: f64,
133        channel: &str,
134        error_type: &str,
135        details: &str,
136    ) {
137        self.total_error_count += 1;
138
139        // Update error counts by type
140        let key = error_type.to_string();
141        if let Some(entry) = self.errors.get_mut(&key) {
142            entry.count += 1;
143            entry.timestamp = timestamp;
144            entry.channel = channel.to_string();
145            entry.details = details.to_string();
146        } else {
147            self.errors.insert(
148                key,
149                ErrorEntry {
150                    timestamp,
151                    channel: channel.to_string(),
152                    error_type: error_type.to_string(),
153                    details: details.to_string(),
154                    count: 1,
155                },
156            );
157        }
158
159        // Add to recent errors ring buffer
160        if self.recent_errors.len() >= MAX_RECENT_ERRORS {
161            self.recent_errors.pop_front();
162        }
163        self.recent_errors
164            .push_back((timestamp, error_type.to_string(), details.to_string()));
165    }
166
167    /// Process a received frame for display (decodes, updates monitors).
168    pub fn process_frame(&mut self, frame: CanFrameDto) {
169        let timestamp = frame.timestamp;
170
171        // Update frame count
172        self.frame_count += 1;
173
174        // Update message monitor
175        self.update_message_monitor(&frame, timestamp);
176
177        // Decode and update signal monitor
178        self.decode_and_update_signals(&frame, timestamp);
179
180        // Add to recent frames ring buffer
181        if self.recent_frames.len() >= MAX_RECENT_FRAMES {
182            self.recent_frames.pop_front();
183        }
184        self.recent_frames.push_back(frame);
185    }
186
187    /// Update message monitor with new frame.
188    fn update_message_monitor(&mut self, frame: &CanFrameDto, timestamp: f64) {
189        let message_name = self.get_message_name(frame.can_id);
190
191        if let Some(entry) = self.messages.get_mut(&frame.can_id) {
192            entry.data = frame.data.clone();
193            entry.dlc = frame.dlc;
194            entry.count += 1;
195            entry.last_update = timestamp;
196        } else {
197            self.messages.insert(
198                frame.can_id,
199                MessageEntry {
200                    can_id: frame.can_id,
201                    message_name,
202                    data: frame.data.clone(),
203                    dlc: frame.dlc,
204                    count: 1,
205                    last_update: timestamp,
206                    last_count: 0,
207                    last_rate_time: timestamp,
208                    rate: 0.0,
209                },
210            );
211        }
212    }
213
214    /// Decode frame and update signal monitor using zero-allocation FastDbc.
215    fn decode_and_update_signals(&mut self, frame: &CanFrameDto, timestamp: f64) {
216        let Some(ref fast_dbc) = self.fast_dbc else {
217            return;
218        };
219
220        // Skip decoding for blacklisted CAN IDs (no match in DBC)
221        if self.decode_blacklist.contains(&frame.can_id) {
222            return;
223        }
224
225        // O(1) message lookup + zero-allocation decode into pre-allocated buffer
226        let msg = if frame.is_extended {
227            fast_dbc.get_extended(frame.can_id)
228        } else {
229            fast_dbc.get(frame.can_id)
230        };
231
232        let Some(msg) = msg else {
233            // Blacklist this CAN ID - no matching message in DBC
234            self.decode_blacklist.insert(frame.can_id);
235            return;
236        };
237
238        // Decode into pre-allocated buffer (zero allocations)
239        let count = msg.decode_into(&frame.data, &mut self.decode_buffer);
240        if count == 0 {
241            return;
242        }
243
244        let can_id = frame.can_id;
245        let message_name = msg.name();
246
247        // Update signals from buffer (zero-allocation key lookup)
248        for (i, signal) in msg.signals().iter().enumerate().take(count) {
249            let value = self.decode_buffer[i];
250            let key: SignalKey = (can_id, i);
251
252            if let Some(entry) = self.signals.get_mut(&key) {
253                entry.value = value;
254                entry.last_update = timestamp;
255                // Update history
256                if entry.history.len() >= MAX_HISTORY_POINTS {
257                    entry.history.pop_front();
258                }
259                entry.history.push_back(value);
260                // Update min/max
261                if value < entry.min_value {
262                    entry.min_value = value;
263                }
264                if value > entry.max_value {
265                    entry.max_value = value;
266                }
267            } else {
268                // First time seeing this signal - allocate names (one-time cost)
269                let mut history = VecDeque::with_capacity(MAX_HISTORY_POINTS);
270                history.push_back(value);
271                self.signals.insert(
272                    key,
273                    SignalEntry {
274                        signal_name: signal.name().to_string(),
275                        message_name: message_name.to_string(),
276                        value,
277                        unit: signal.unit().unwrap_or("").to_string(),
278                        last_update: timestamp,
279                        history,
280                        min_value: value,
281                        max_value: value,
282                    },
283                );
284            }
285        }
286    }
287
288    /// Get message name from DBC or return "-".
289    fn get_message_name(&self, can_id: u32) -> String {
290        if let Some(ref fast_dbc) = self.fast_dbc {
291            // O(1) lookup via FastDbc
292            if let Some(msg) = fast_dbc.get(can_id) {
293                return msg.name().to_string();
294            }
295        }
296        "-".to_string()
297    }
298
299    /// Update rates for all messages (call periodically).
300    pub fn update_rates(&mut self) {
301        let now = Instant::now();
302        let elapsed = now.duration_since(self.last_rate_update).as_secs_f64();
303
304        if elapsed >= 0.5 {
305            // Update overall frame rate
306            let frame_delta = self.frame_count - self.last_frame_count;
307            self.frame_rate = frame_delta as f64 / elapsed;
308            self.last_frame_count = self.frame_count;
309
310            // Update per-message rates
311            let current_time = self.start_time.elapsed().as_secs_f64();
312            for entry in self.messages.values_mut() {
313                let count_delta = entry.count - entry.last_count;
314                let time_delta = current_time - entry.last_rate_time;
315                if time_delta > 0.0 {
316                    entry.rate = count_delta as f64 / time_delta;
317                }
318                entry.last_count = entry.count;
319                entry.last_rate_time = current_time;
320            }
321
322            self.last_rate_update = now;
323        }
324    }
325
326    /// Generate update for frontend with pre-rendered HTML.
327    pub fn generate_update(&self) -> LiveCaptureUpdate {
328        let elapsed = self.start_time.elapsed().as_secs_f64();
329
330        let stats = CaptureStatsDto {
331            frame_count: self.frame_count,
332            message_count: self.messages.len() as u32,
333            signal_count: self.signals.len() as u32,
334            frame_rate: self.frame_rate,
335            elapsed_secs: elapsed,
336            capture_file: Some(self.capture_file.clone()),
337        };
338
339        // Pre-render messages HTML (sorted by CAN ID)
340        let mut messages: Vec<_> = self.messages.values().collect();
341        messages.sort_by_key(|e| e.can_id);
342        let messages_html = messages
343            .iter()
344            .map(|e| {
345                let id_hex = format!("{:03X}", e.can_id);
346                let data_hex = e
347                    .data
348                    .iter()
349                    .map(|b| format!("{:02X}", b))
350                    .collect::<Vec<_>>()
351                    .join(" ");
352                format!(
353                    "<tr><td class=\"cv-cell-id\">0x{}</td><td class=\"cv-cell-name\">{}</td><td class=\"cv-cell-data\">{}</td><td>{}</td><td>{:.1}/s</td></tr>",
354                    id_hex, e.message_name, data_hex, e.count, e.rate
355                )
356            })
357            .collect::<Vec<_>>()
358            .join("");
359
360        // Pre-render signals HTML (responsive grid with sparklines)
361        let mut signals: Vec<_> = self.signals.values().collect();
362        signals.sort_by(|a, b| {
363            a.message_name
364                .cmp(&b.message_name)
365                .then_with(|| a.signal_name.cmp(&b.signal_name))
366        });
367
368        let mut signals_html = String::new();
369        let mut current_message: Option<&str> = None;
370        for e in &signals {
371            // Add group header when message changes
372            if current_message != Some(&e.message_name) {
373                signals_html.push_str(&format!(
374                    "<div class=\"cv-signal-group-header\">{}</div>",
375                    e.message_name
376                ));
377                current_message = Some(&e.message_name);
378            }
379
380            // Format value - use consistent decimal places for stability
381            let value_str = if e.value.abs() >= 1000.0 {
382                format!("{:.0}", e.value)
383            } else if e.value.abs() >= 100.0 {
384                format!("{:.1}", e.value)
385            } else if e.value.fract() == 0.0 && e.value.abs() < 100.0 {
386                format!("{:.0}", e.value)
387            } else {
388                format!("{:.2}", e.value)
389            };
390
391            // Generate sparkline SVG
392            let sparkline = Self::render_sparkline(&e.history, e.min_value, e.max_value);
393
394            // Format min/max
395            let min_str = if e.min_value.fract() == 0.0 {
396                format!("{}", e.min_value as i64)
397            } else {
398                format!("{:.2}", e.min_value)
399            };
400            let max_str = if e.max_value.fract() == 0.0 {
401                format!("{}", e.max_value as i64)
402            } else {
403                format!("{:.2}", e.max_value)
404            };
405
406            signals_html.push_str(&format!(
407                "<div class=\"cv-signal-row\">\
408                    <div class=\"cv-signal-info\">\
409                        <span class=\"cv-signal-name\">{}</span>\
410                        <span class=\"cv-signal-value\">{} <span class=\"cv-signal-unit\">{}</span></span>\
411                    </div>\
412                    <div class=\"cv-signal-chart\">\
413                        <span class=\"cv-signal-min\">{}</span>\
414                        {}\
415                        <span class=\"cv-signal-max\">{}</span>\
416                    </div>\
417                </div>",
418                e.signal_name, value_str, e.unit, min_str, sparkline, max_str
419            ));
420        }
421
422        // Pre-render frames HTML (ring buffer already limited to MAX_RECENT_FRAMES)
423        let frames_html = self
424            .recent_frames
425            .iter()
426            .take(MAX_RECENT_FRAMES) // Safety limit
427            .map(|f| {
428                let id_hex = format!("{:03X}", f.can_id);
429                let data_hex = f
430                    .data
431                    .iter()
432                    .map(|b| format!("{:02X}", b))
433                    .collect::<Vec<_>>()
434                    .join(" ");
435                let flags = Self::format_flags(f);
436                format!(
437                    "<tr><td class=\"cv-cell-dim\">{:.6}</td><td class=\"cv-cell-id\">0x{}</td><td>{}</td><td class=\"cv-cell-data\">{}</td><td>{}</td></tr>",
438                    f.timestamp, id_hex, f.dlc, data_hex, flags
439                )
440            })
441            .collect::<Vec<_>>()
442            .join("");
443
444        // Pre-render errors HTML - as table rows with error type summary and recent errors
445        let mut errors_html = String::new();
446
447        // Sort errors by count for display
448        let mut error_types: Vec<_> = self.errors.values().collect();
449        error_types.sort_by(|a, b| b.count.cmp(&a.count));
450
451        // Render error summary rows
452        for e in error_types {
453            errors_html.push_str(&format!(
454                "<tr class=\"cv-error-summary-row\">\
455                    <td class=\"cv-cell-dim\">{:.6}</td>\
456                    <td>{}</td>\
457                    <td class=\"cv-cell-error-type\">{}</td>\
458                    <td>{}</td>\
459                    <td class=\"cv-cell-value\">{}</td>\
460                </tr>",
461                e.timestamp, e.channel, e.error_type, e.details, e.count
462            ));
463        }
464
465        // Pre-format stats
466        let secs = elapsed as u64;
467        let mins = secs / 60;
468        let remaining_secs = secs % 60;
469        let stats_html = StatsHtml {
470            message_count: self.messages.len().to_string(),
471            frame_count: self.frame_count.to_string(),
472            frame_rate: format!("{:.0}/s", self.frame_rate),
473            elapsed: format!("{}:{:02}", mins, remaining_secs),
474        };
475
476        LiveCaptureUpdate {
477            stats,
478            messages_html,
479            signals_html,
480            frames_html,
481            errors_html,
482            stats_html,
483            message_count: self.messages.len() as u32,
484            signal_count: self.signals.len() as u32,
485            frame_count: self.recent_frames.len(),
486            error_count: self.total_error_count as u32,
487        }
488    }
489
490    /// Format CAN frame flags as string.
491    fn format_flags(frame: &CanFrameDto) -> &'static str {
492        match (frame.is_extended, frame.is_fd, frame.brs, frame.esi) {
493            (false, false, false, false) => "-",
494            (true, false, false, false) => "EXT",
495            (false, true, false, false) => "FD",
496            (false, true, true, false) => "FD, BRS",
497            (false, true, false, true) => "FD, ESI",
498            (false, true, true, true) => "FD, BRS, ESI",
499            (true, true, false, false) => "EXT, FD",
500            (true, true, true, false) => "EXT, FD, BRS",
501            (true, true, false, true) => "EXT, FD, ESI",
502            (true, true, true, true) => "EXT, FD, BRS, ESI",
503            _ => "-",
504        }
505    }
506
507    /// Render a sparkline SVG from history values.
508    fn render_sparkline(history: &VecDeque<f64>, min: f64, max: f64) -> String {
509        // ViewBox dimensions (SVG will scale to fill container via CSS)
510        const VB_WIDTH: f64 = 200.0;
511        const VB_HEIGHT: f64 = 32.0;
512        const PADDING: f64 = 2.0;
513
514        if history.is_empty() {
515            return "<div class=\"cv-sparkline\"></div>".to_string();
516        }
517
518        let range = max - min;
519        let effective_range = if range.abs() < f64::EPSILON {
520            1.0
521        } else {
522            range
523        };
524        let n = history.len();
525
526        // Build polyline points
527        let points: Vec<String> = history
528            .iter()
529            .enumerate()
530            .map(|(i, &v)| {
531                let x = PADDING + (i as f64 / (n.max(2) - 1) as f64) * (VB_WIDTH - 2.0 * PADDING);
532                let y = PADDING + (1.0 - (v - min) / effective_range) * (VB_HEIGHT - 2.0 * PADDING);
533                format!("{:.1},{:.1}", x, y)
534            })
535            .collect();
536
537        format!(
538            "<div class=\"cv-sparkline\">\
539                <svg viewBox=\"0 0 {} {}\" preserveAspectRatio=\"none\">\
540                    <polyline points=\"{}\" fill=\"none\" stroke=\"var(--cv-accent)\" stroke-width=\"1.5\" vector-effect=\"non-scaling-stroke\"/>\
541                </svg>\
542            </div>",
543            VB_WIDTH,
544            VB_HEIGHT,
545            points.join(" ")
546        )
547    }
548}