use crate::serial_core::frame::TestFrame; use std::collections::HashMap; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const MAX_POINTS: usize = 28; const MAX_SUMMARY_POINTS: usize = 42; const PANEL_STALE_AFTER: Duration = Duration::from_millis(2400); #[derive(serde::Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct HudPacket { pub ts: u64, pub panels: Vec, pub summary: HudSummary, pub pressure_matrix: Option>, } #[derive(serde::Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct HudSummary { pub label: String, pub points: Vec, pub latest: Option, pub min: Option, pub max: Option, } #[derive(serde::Serialize, Clone, Copy)] #[serde(rename_all = "lowercase")] pub enum HudPanelSide { Left, Right, } #[derive(serde::Serialize, Clone, Copy)] #[serde(rename_all = "lowercase")] pub enum HudTone { Cyan, Lime, Orange, Violet, Gold, Rose, } #[derive(serde::Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct HudSignalPanel { pub id: String, pub code: String, pub title: String, pub side: HudPanelSide, pub active: bool, pub series: Vec, pub icons: Vec, pub latest: Option, pub min: Option, pub max: Option, } #[derive(serde::Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct HudSignalSeries { pub id: String, pub tone: HudTone, pub points: Vec, } #[derive(serde::Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct HudSignalIcon { pub id: String, pub label: String, pub tone: HudTone, } struct HudPanelUpdate { source_id: String, values: Vec, } struct PanelEntry { panel: HudSignalPanel, last_seen: Instant, } pub struct HudChartState { panels: HashMap, order: Vec, summary_points: Vec, pressure_matrix: Option>, last_frame_seen: Option, } impl HudChartState { pub fn new() -> Self { Self { panels: HashMap::new(), order: Vec::new(), summary_points: Vec::new(), pressure_matrix: None, last_frame_seen: None, } } pub fn record_summary(&mut self, value: f32) { push_summary_point(&mut self.summary_points, value); } pub fn record_pressure_matrix(&mut self, values: &[i32]) { if values.is_empty() { return; } self.pressure_matrix = Some(values.iter().map(|value| *value as f32).collect()); } pub fn apply_frame(&mut self, frame: &TestFrame, decoded_values: Option<&[i32]>) -> HudPacket { let now = Instant::now(); self.last_frame_seen = Some(now); for update in expand_frame_updates(frame, decoded_values) { self.apply_update(update, now); } self.prune_stale_at(now); self.snapshot() } pub fn prune_stale(&mut self) -> Option { let before = self.panels.len(); let summary_points_before = self.summary_points.len(); self.prune_stale_at(Instant::now()); if before == self.panels.len() && summary_points_before == self.summary_points.len() { return None; } Some(self.snapshot()) } fn apply_update(&mut self, update: HudPanelUpdate, now: Instant) { if update.values.is_empty() { return; } if !self.panels.contains_key(&update.source_id) { let next_side = side_for_index(self.order.len()); self.order.push(update.source_id.clone()); self.panels.insert( update.source_id.clone(), PanelEntry { panel: build_panel(&update.source_id, next_side, update.values.len()), last_seen: now, }, ); } let entry = self .panels .get_mut(&update.source_id) .expect("panel entry should exist after insertion"); entry.last_seen = now; entry.panel.active = true; ensure_panel_channels(&mut entry.panel, update.values.len()); for (index, value) in update.values.into_iter().enumerate() { if let Some(series) = entry.panel.series.get_mut(index) { push_point(&mut series.points, value); } } refresh_panel_stats(&mut entry.panel); } fn prune_stale_at(&mut self, now: Instant) { self.panels .retain(|_, entry| now.duration_since(entry.last_seen) <= PANEL_STALE_AFTER); self.order.retain(|id| self.panels.contains_key(id)); let summary_stale = self .last_frame_seen .map(|last_seen| now.duration_since(last_seen) > PANEL_STALE_AFTER) .unwrap_or(false); if summary_stale { self.summary_points.clear(); self.pressure_matrix = None; self.last_frame_seen = None; } } fn snapshot(&mut self) -> HudPacket { self.rebalance_sides(); let panels = self .order .iter() .filter_map(|id| self.panels.get(id).map(|entry| entry.panel.clone())) .collect(); HudPacket { ts: now_millis(), panels, summary: build_summary(&self.summary_points), pressure_matrix: self.pressure_matrix.clone(), } } fn rebalance_sides(&mut self) { for (index, id) in self.order.iter().enumerate() { if let Some(entry) = self.panels.get_mut(id) { entry.panel.side = side_for_index(index); } } } } impl Default for HudChartState { fn default() -> Self { Self::new() } } fn build_panel(source_id: &str, side: HudPanelSide, channel_count: usize) -> HudSignalPanel { HudSignalPanel { id: format!("panel-{source_id}"), code: source_id.to_string(), title: format!("Source {source_id}"), side, active: true, series: build_panel_series(source_id, channel_count, &[]), icons: build_panel_icons(source_id, channel_count), latest: None, min: None, max: None, } } fn expand_frame_updates(frame: &TestFrame, decoded_values: Option<&[i32]>) -> Vec { if let Some(values) = decoded_values { if values.is_empty() { return Vec::new(); } return vec![HudPanelUpdate { source_id: format_source_id(frame.cmd), values: values.iter().map(|value| *value as f32).collect(), }]; } let chunks = frame.payload.chunks_exact(4); if !frame.payload.is_empty() && chunks.remainder().is_empty() { return chunks.map(build_update_from_chunk).collect(); } vec![HudPanelUpdate { source_id: format_source_id(frame.cmd), values: fallback_values(frame), }] } fn build_update_from_chunk(chunk: &[u8]) -> HudPanelUpdate { HudPanelUpdate { source_id: format_source_id(chunk[0]), values: chunk[1..] .iter() .enumerate() .map(|(index, byte)| normalize_value(*byte, tone_for_index(index))) .collect(), } } fn fallback_values(frame: &TestFrame) -> Vec { let mut bytes = frame.payload.clone(); if bytes.is_empty() { bytes.extend([ frame.cmd, frame.length as u8, frame.checksum, frame.cmd.wrapping_add(frame.checksum), ]); } while bytes.len() < 3 { let previous = *bytes.last().unwrap_or(&frame.cmd); bytes.push( previous .wrapping_add(frame.cmd) .wrapping_add(bytes.len() as u8), ); } bytes .into_iter() .enumerate() .map(|(index, byte)| normalize_value(byte, tone_for_index(index))) .collect() } fn normalize_value(byte: u8, tone: HudTone) -> f32 { let base = (byte as f32 / 255.0) * 100.0; let offset = match tone { HudTone::Cyan => 6.0, HudTone::Lime => 0.0, HudTone::Orange => -6.0, HudTone::Violet => 10.0, HudTone::Gold => -10.0, HudTone::Rose => 3.0, }; (base + offset).clamp(0.0, 100.0) } fn format_source_id(byte: u8) -> String { if byte.is_ascii_alphanumeric() { (byte as char).to_ascii_uppercase().to_string() } else { format!("CH{:02X}", byte) } } fn side_for_index(index: usize) -> HudPanelSide { if index % 2 == 0 { HudPanelSide::Left } else { HudPanelSide::Right } } fn push_point(points: &mut Vec, value: f32) { if points.len() >= MAX_POINTS { points.remove(0); } points.push((value * 10.0).round() / 10.0); } fn build_panel_series( source_id: &str, channel_count: usize, previous: &[HudSignalSeries], ) -> Vec { (0..channel_count) .map(|index| HudSignalSeries { id: format!("{source_id}-series-{}", index + 1), tone: tone_for_index(index), points: previous .get(index) .map(|series| series.points.clone()) .unwrap_or_default(), }) .collect() } fn build_panel_icons(source_id: &str, channel_count: usize) -> Vec { (0..channel_count) .map(|index| HudSignalIcon { id: format!("{source_id}-icon-{}", index + 1), label: if channel_count == 1 { "TOTAL".to_string() } else { format!("{source_id}-{}", index + 1) }, tone: tone_for_index(index), }) .collect() } fn ensure_panel_channels(panel: &mut HudSignalPanel, channel_count: usize) { if panel.series.len() == channel_count && panel.icons.len() == channel_count { return; } panel.series = build_panel_series(&panel.code, channel_count, &panel.series); panel.icons = build_panel_icons(&panel.code, channel_count); } fn refresh_panel_stats(panel: &mut HudSignalPanel) { let latest_values: Vec = panel .series .iter() .filter_map(|series| series.points.last().copied()) .collect(); panel.latest = if latest_values.is_empty() { None } else { Some(latest_values.iter().sum::() / latest_values.len() as f32) }; panel.min = panel .series .iter() .flat_map(|series| series.points.iter().copied()) .reduce(f32::min); panel.max = panel .series .iter() .flat_map(|series| series.points.iter().copied()) .reduce(f32::max); } fn tone_for_index(index: usize) -> HudTone { match index % 6 { 0 => HudTone::Cyan, 1 => HudTone::Lime, 2 => HudTone::Orange, 3 => HudTone::Violet, 4 => HudTone::Gold, _ => HudTone::Rose, } } fn push_summary_point(points: &mut Vec, value: f32) { if points.len() >= MAX_SUMMARY_POINTS { points.remove(0); } points.push((value * 10.0).round() / 10.0); } fn build_summary(points: &[f32]) -> HudSummary { HudSummary { label: "TOTAL".to_string(), points: points.to_vec(), latest: points.last().copied(), min: points.iter().copied().reduce(f32::min), max: points.iter().copied().reduce(f32::max), } } fn now_millis() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis() as u64) .unwrap_or_default() } // #[cfg(test)] // mod tests { // use super::*; // // fn sample_frame() -> TestFrame { // TestFrame { // header: [0xAA, 0x55], // cmd: 0x01, // length: 4, // payload: vec![0x00, 0x0A, 0x00, 0x14], // checksum: 0, // // } // } // // #[test] // fn prune_stale_clears_panels_and_summary_after_timeout() { // let mut state = HudChartState::new(); // let frame = sample_frame(); // // state.record_summary(30.0); // let _ = state.apply_frame(&frame, Some(&[10, 20])); // // let stale_now = Instant::now(); // let stale_seen = stale_now - PANEL_STALE_AFTER - Duration::from_millis(1); // // state.last_frame_seen = Some(stale_seen); // // for entry in state.panels.values_mut() { // entry.last_seen = stale_seen; // } // // let packet = state // .prune_stale() // .expect("stale data should emit an update"); // // assert!(packet.panels.is_empty()); // assert!(packet.summary.points.is_empty()); // assert!(state.panels.is_empty()); // assert!(state.summary_points.is_empty()); // } // // #[test] // fn prune_stale_keeps_recent_summary_points() { // let mut state = HudChartState::new(); // let frame = sample_frame(); // // state.record_summary(30.0); // let _ = state.apply_frame(&frame, Some(&[10, 20])); // // state.last_frame_seen = Some(Instant::now()); // // assert!(state.prune_stale().is_none()); // assert_eq!(state.summary_points, vec![30.0]); // assert_eq!(state.panels.len(), 1); // } // }