501 lines
13 KiB
Rust
501 lines
13 KiB
Rust
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<HudSignalPanel>,
|
|
pub summary: HudSummary,
|
|
pub pressure_matrix: Option<Vec<f32>>,
|
|
}
|
|
|
|
#[derive(serde::Serialize, Clone)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct HudSummary {
|
|
pub label: String,
|
|
pub points: Vec<f32>,
|
|
pub latest: Option<f32>,
|
|
pub min: Option<f32>,
|
|
pub max: Option<f32>,
|
|
}
|
|
|
|
#[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<HudSignalSeries>,
|
|
pub icons: Vec<HudSignalIcon>,
|
|
pub latest: Option<f32>,
|
|
pub min: Option<f32>,
|
|
pub max: Option<f32>,
|
|
}
|
|
|
|
#[derive(serde::Serialize, Clone)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct HudSignalSeries {
|
|
pub id: String,
|
|
pub tone: HudTone,
|
|
pub points: Vec<f32>,
|
|
}
|
|
|
|
#[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<f32>,
|
|
}
|
|
|
|
struct PanelEntry {
|
|
panel: HudSignalPanel,
|
|
last_seen: Instant,
|
|
}
|
|
|
|
pub struct HudChartState {
|
|
panels: HashMap<String, PanelEntry>,
|
|
order: Vec<String>,
|
|
summary_points: Vec<f32>,
|
|
pressure_matrix: Option<Vec<f32>>,
|
|
last_frame_seen: Option<Instant>,
|
|
}
|
|
|
|
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<HudPacket> {
|
|
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<HudPanelUpdate> {
|
|
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<f32> {
|
|
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<f32>, 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<HudSignalSeries> {
|
|
(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<HudSignalIcon> {
|
|
(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<f32> = 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::<f32>() / 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<f32>, 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);
|
|
// }
|
|
// }
|