use std::cmp::Ordering;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::time::Instant;
use crate::engine::{FieldRef, PgnDatabase, PgnInfo, Units, field};
use indexmap::IndexMap;
use serde_json::{Map, Value};
use crate::cli::tui::device_cache::{CachedInfo, NameKey};
pub type EntryKey = (u32, u8, Option<String>);
#[derive(Debug, Clone)]
pub struct Entry {
pub pgn: u32,
pub src: u8,
pub secondary: Option<String>,
pub description: String,
pub line: Value,
pub last_update: Instant,
pub count: u64,
pub first_seen: Instant,
pub first_stamp_ms: Option<i64>,
pub last_stamp_ms: Option<i64>,
pub history_indices: Vec<usize>,
}
impl Entry {
pub fn interval(&self) -> Option<std::time::Duration> {
if self.count < 2 {
return None;
}
let denom = self.count.saturating_sub(1) as u32;
if let (Some(first), Some(last)) = (self.first_stamp_ms, self.last_stamp_ms) {
let span_ms = (last - first).max(0) as u64;
return Some(std::time::Duration::from_millis(span_ms) / denom);
}
let span = self.last_update.saturating_duration_since(self.first_seen);
Some(span / denom)
}
}
#[derive(Debug, Clone, Default)]
pub struct DeviceInfo {
pub src: u8,
pub manufacturer: String,
pub model: String,
pub software: String,
pub installation: String,
pub pgn_count: usize,
}
#[derive(Debug, Clone, Default)]
pub struct Nmea0183Device {
pub muted: bool,
pub sentences: BTreeMap<String, bool>,
}
#[derive(Debug, Clone)]
pub struct Nmea0183Row {
pub src: u8,
pub sentence: Option<String>,
pub muted: bool,
}
pub const OVERRIDE_TTL: std::time::Duration = std::time::Duration::from_secs(6);
#[derive(Debug, Clone)]
pub struct OverrideRow {
pub src: u8,
pub pgn: u32,
pub interval_ms: u32,
pub manufacturer_code: Option<u16>,
pub industry_code: Option<u8>,
pub description: String,
pub last_seen: Instant,
}
#[derive(Debug, Clone)]
pub struct Progress {
pub label: String,
pub done: usize,
pub total: usize,
}
#[derive(Debug, Clone)]
pub struct PgnLoadRow {
pub pgn: u32,
pub description: String,
pub sources: usize,
pub count: u64,
pub rate: f32,
}
pub const NMEA0183_ALL: &str = "ALL";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Live,
Log,
}
#[derive(Debug, Clone)]
pub struct Status {
pub mode: Mode,
pub host: String,
pub snapshot_port: u16,
pub stream_port: u16,
pub snapshot_loaded: bool,
pub stream_connected: bool,
pub messages_seen: u64,
pub last_error: Option<String>,
}
impl Status {
pub fn new_live(host: String, snapshot_port: u16, stream_port: u16) -> Self {
Self {
mode: Mode::Live,
host,
snapshot_port,
stream_port,
snapshot_loaded: false,
stream_connected: false,
messages_seen: 0,
last_error: None,
}
}
pub fn new_log(path_display: String) -> Self {
Self {
mode: Mode::Log,
host: path_display,
snapshot_port: 0,
stream_port: 0,
snapshot_loaded: false,
stream_connected: false,
messages_seen: 0,
last_error: None,
}
}
pub fn new_idle() -> Self {
Self {
mode: Mode::Log,
host: "(no source — File ▸ Load / Connect)".to_string(),
snapshot_port: 0,
stream_port: 0,
snapshot_loaded: true,
stream_connected: false,
messages_seen: 0,
last_error: None,
}
}
}
#[derive(Debug, Clone)]
pub struct HistoryRecord {
pub timestamp: Option<String>,
pub seen_at: Instant,
pub pgn: u32,
pub src: u8,
pub secondary: Option<String>,
pub description: String,
pub line: Value,
}
pub struct AppState {
pub entries: IndexMap<EntryKey, Entry>,
pub history: Vec<HistoryRecord>,
pub status: Status,
pub notice: Option<String>,
pub save_progress: Option<Progress>,
pub device_cache: HashMap<NameKey, CachedInfo>,
pub src_to_name: HashMap<u8, NameKey>,
pub nmea0183: BTreeMap<u8, Nmea0183Device>,
pub raw_lines: Vec<String>,
pub overrides: BTreeMap<(u8, u32), OverrideRow>,
}
impl AppState {
pub fn new(status: Status, device_cache: HashMap<NameKey, CachedInfo>) -> Self {
Self {
entries: IndexMap::new(),
history: Vec::new(),
status,
notice: None,
save_progress: None,
device_cache,
src_to_name: HashMap::new(),
nmea0183: BTreeMap::new(),
raw_lines: Vec::new(),
overrides: BTreeMap::new(),
}
}
pub fn upsert(
&mut self,
pgn: u32,
src: u8,
secondary: Option<String>,
description: String,
line: Value,
) {
let line = normalize_camel(line);
let description = line
.pointer("/description")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or(description);
if pgn == 262657 {
self.apply_nmea0183_report(&line);
self.status.messages_seen = self.status.messages_seen.saturating_add(1);
return;
}
if pgn == 262658 {
self.apply_override_report(&line);
self.status.messages_seen = self.status.messages_seen.saturating_add(1);
return;
}
let key = (pgn, src, secondary.clone());
let now = Instant::now();
let timestamp = line
.pointer("/timestamp")
.and_then(Value::as_str)
.map(str::to_string);
let stamp_ms = timestamp.as_deref().and_then(crate::engine::parse_iso_ms);
let history_idx = self.history.len();
self.history.push(HistoryRecord {
timestamp,
seen_at: now,
pgn,
src,
secondary: secondary.clone(),
description: description.clone(),
line: line.clone(),
});
match self.entries.get_mut(&key) {
Some(e) => {
e.line = line;
e.description = description;
e.last_update = now;
e.count += 1;
e.history_indices.push(history_idx);
if let Some(ms) = stamp_ms {
e.last_stamp_ms = Some(ms);
if e.first_stamp_ms.is_none() {
e.first_stamp_ms = Some(ms);
}
}
}
None => {
self.entries.insert(
key,
Entry {
pgn,
src,
secondary,
description,
line,
last_update: now,
count: 1,
first_seen: now,
first_stamp_ms: stamp_ms,
last_stamp_ms: stamp_ms,
history_indices: vec![history_idx],
},
);
}
}
self.observe_for_cache(pgn, src, self.history.last().map(|h| h.line.clone()));
self.status.messages_seen = self.status.messages_seen.saturating_add(1);
}
fn observe_for_cache(&mut self, pgn: u32, src: u8, line: Option<Value>) {
let Some(line) = line else { return };
let now_stamp = line
.pointer("/timestamp")
.and_then(Value::as_str)
.map(str::to_string);
match pgn {
60928 => {
if let Some(key) = name_key_from_claim(&line) {
self.src_to_name.insert(src, key);
let info = CachedInfo {
manufacturer_name: manufacturer_name_from_claim(&line),
device_function: field_number(
&line,
field::iso_address_claim::DEVICE_FUNCTION,
)
.map(|v| v as u32),
device_class: field_number(&line, field::iso_address_claim::DEVICE_CLASS)
.map(|v| v as u32),
industry_group: field_number(
&line,
field::iso_address_claim::INDUSTRY_GROUP,
)
.map(|v| v as u32),
first_seen: now_stamp.clone(),
last_seen: now_stamp,
..Default::default()
};
self.device_cache.entry(key).or_default().merge_from(info);
}
}
126996 => {
let Some(key) = self.src_to_name.get(&src).copied() else {
return;
};
use field::product_information as pi;
let info = CachedInfo {
product_code: field_number(&line, pi::PRODUCT_CODE).map(|v| v as u32),
model_id: field_text(&line, pi::MODEL_ID),
software_version: field_text(&line, pi::SOFTWARE_VERSION_CODE),
model_version: field_text(&line, pi::MODEL_VERSION),
model_serial_code: field_text(&line, pi::MODEL_SERIAL_CODE),
nmea_db_version: field_number(&line, pi::NMEA2000_VERSION).map(|v| v as u32),
certification_level: field_number(&line, pi::CERTIFICATION_LEVEL)
.map(|v| v as u32),
load_equivalency: field_number(&line, pi::LOAD_EQUIVALENCY).map(|v| v as u32),
last_seen: now_stamp,
..Default::default()
};
self.device_cache.entry(key).or_default().merge_from(info);
}
126998 => {
let Some(key) = self.src_to_name.get(&src).copied() else {
return;
};
use field::configuration_information as ci;
let info = CachedInfo {
installation_description_1: field_text(&line, ci::INSTALLATION_DESCRIPTION1),
installation_description_2: field_text(&line, ci::INSTALLATION_DESCRIPTION2),
manufacturer_information: field_text(&line, ci::MANUFACTURER_INFORMATION),
last_seen: now_stamp,
..Default::default()
};
self.device_cache.entry(key).or_default().merge_from(info);
}
_ => {}
}
}
pub fn device_list(&self) -> Vec<DeviceInfo> {
let mut by_src: BTreeMap<u8, DeviceInfo> = BTreeMap::new();
let mut pgns_per_src: BTreeMap<u8, std::collections::BTreeSet<u32>> = BTreeMap::new();
for entry in self.entries.values() {
pgns_per_src.entry(entry.src).or_default().insert(entry.pgn);
let dev = by_src.entry(entry.src).or_insert_with(|| DeviceInfo {
src: entry.src,
..Default::default()
});
match entry.pgn {
60928 => fill_from_claim(dev, &entry.line),
126996 => fill_from_product_info(dev, &entry.line),
126998 => fill_from_config_info(dev, &entry.line),
_ => {}
}
}
for (src, set) in pgns_per_src {
if let Some(dev) = by_src.get_mut(&src) {
dev.pgn_count = set.len();
}
}
for dev in by_src.values_mut() {
let Some(key) = self.src_to_name.get(&dev.src) else {
continue;
};
let Some(cached) = self.device_cache.get(key) else {
continue;
};
if dev.manufacturer.is_empty()
&& let Some(m) = &cached.manufacturer_name
{
dev.manufacturer = m.clone();
}
if dev.model.is_empty()
&& let Some(m) = &cached.model_id
{
dev.model = m.clone();
}
if dev.software.is_empty()
&& let Some(s) = &cached.software_version
{
dev.software = s.clone();
}
if dev.installation.is_empty()
&& let Some(i) = &cached.installation_description_1
{
dev.installation = i.clone();
}
}
by_src.into_values().collect()
}
fn apply_nmea0183_report(&mut self, line: &Value) {
use field::canboat_nmea0183_filter as fl;
if field_number(line, fl::FUNCTION) != Some(0) {
return;
}
let Some(src) = field_number(line, fl::SOURCE) else {
return;
};
let Some(sentence) = field_text(line, fl::SENTENCE) else {
return;
};
let muted = field_number(line, fl::MUTED).unwrap_or(0) != 0;
let dev = self.nmea0183.entry(src as u8).or_default();
if sentence == NMEA0183_ALL {
dev.muted = muted;
} else {
dev.sentences.insert(sentence, muted);
}
}
fn apply_override_report(&mut self, line: &Value) {
use field::canboat_pgn_override as ov;
if field_number(line, ov::FUNCTION) != Some(0) {
return; }
let Some(src) = field_number(line, ov::SOURCE) else {
return;
};
let Some(pgn) = field_number(line, ov::PGN) else {
return;
};
let src = src as u8;
let pgn = pgn as u32;
let interval_ms = field_number(line, ov::INTERVAL_MS).unwrap_or(0).max(0) as u32;
let manufacturer_code = field_number(line, ov::MANUFACTURER_CODE)
.filter(|v| *v != 0xffff)
.map(|v| v as u16);
let industry_code = field_number(line, ov::INDUSTRY_CODE)
.filter(|v| *v != 0xff)
.map(|v| v as u8);
let description = variant_for(pgn, manufacturer_code)
.map(|p| p.description.to_string())
.unwrap_or_default();
self.overrides.insert(
(src, pgn),
OverrideRow {
src,
pgn,
interval_ms,
manufacturer_code,
industry_code,
description,
last_seen: Instant::now(),
},
);
}
pub fn override_rows(&self) -> Vec<OverrideRow> {
self.overrides
.values()
.filter(|r| r.last_seen.elapsed() < OVERRIDE_TTL)
.cloned()
.collect()
}
pub fn forget_override(&mut self, src: u8, pgn: u32) {
self.overrides.remove(&(src, pgn));
}
pub fn prune_overrides(&mut self) {
self.overrides
.retain(|_, r| r.last_seen.elapsed() < OVERRIDE_TTL);
}
pub fn nmea0183_rows(&self) -> Vec<Nmea0183Row> {
let mut rows = Vec::new();
for (src, dev) in &self.nmea0183 {
rows.push(Nmea0183Row {
src: *src,
sentence: None,
muted: dev.muted,
});
for (sentence, muted) in &dev.sentences {
rows.push(Nmea0183Row {
src: *src,
sentence: Some(sentence.clone()),
muted: *muted,
});
}
}
rows
}
pub fn pgn_load_rows(&self) -> Vec<PgnLoadRow> {
let dur = self.capture_duration_secs();
let mut agg: HashMap<u32, (String, HashSet<u8>, u64)> = HashMap::new();
for e in self.entries.values() {
if e.count == 0 {
continue;
}
let slot = agg.entry(e.pgn).or_default();
if slot.0.is_empty() && !e.description.is_empty() {
slot.0 = e.description.clone();
}
slot.1.insert(e.src);
slot.2 += e.count;
}
let mut rows: Vec<PgnLoadRow> = agg
.into_iter()
.map(|(pgn, (description, srcs, count))| PgnLoadRow {
pgn,
description,
sources: srcs.len(),
count,
rate: if dur > 0.0 { count as f32 / dur } else { 0.0 },
})
.collect();
rows.sort_by(|a, b| {
b.rate
.partial_cmp(&a.rate)
.unwrap_or(Ordering::Equal)
.then_with(|| b.count.cmp(&a.count))
.then_with(|| a.pgn.cmp(&b.pgn))
});
rows
}
pub fn capture_duration_secs(&self) -> f32 {
let (mut min_ms, mut max_ms) = (i64::MAX, i64::MIN);
for e in self.entries.values() {
if let Some(f) = e.first_stamp_ms {
min_ms = min_ms.min(f);
}
if let Some(l) = e.last_stamp_ms {
max_ms = max_ms.max(l);
}
}
if max_ms > min_ms {
return (max_ms - min_ms) as f32 / 1000.0;
}
let (mut earliest, mut latest): (Option<Instant>, Option<Instant>) = (None, None);
for e in self.entries.values() {
earliest = Some(earliest.map_or(e.first_seen, |x: Instant| x.min(e.first_seen)));
latest = Some(latest.map_or(e.last_update, |x: Instant| x.max(e.last_update)));
}
match (earliest, latest) {
(Some(a), Some(b)) if b > a => b.duration_since(a).as_secs_f32(),
_ => 0.0,
}
}
pub fn entries_for_src(&self, src: u8) -> Vec<&Entry> {
let mut v: Vec<&Entry> = self.entries.values().filter(|e| e.src == src).collect();
v.sort_by(|a, b| {
a.pgn
.cmp(&b.pgn)
.then_with(|| a.secondary.cmp(&b.secondary))
});
v
}
pub fn pgn_lists_for_src(&self, src: u8) -> PgnLists {
let mut tx = Vec::new();
let mut rx = Vec::new();
for entry in self.entries.values() {
if entry.pgn != 126464 || entry.src != src {
continue;
}
let direction = field_number(
&entry.line,
field::pgn_list_transmit_and_receive::FUNCTION_CODE,
);
let list = collect_pgn_list(&entry.line);
match direction {
Some(0) => tx.extend(list),
Some(1) => rx.extend(list),
_ => {}
}
}
tx.sort_unstable();
tx.dedup();
rx.sort_unstable();
rx.dedup();
PgnLists { tx, rx }
}
}
#[derive(Debug, Clone, Default)]
pub struct PgnLists {
pub tx: Vec<u32>,
pub rx: Vec<u32>,
}
impl PgnLists {
pub fn is_empty(&self) -> bool {
self.tx.is_empty() && self.rx.is_empty()
}
}
fn name_key_from_claim(line: &Value) -> Option<NameKey> {
let mfr = field_number(line, field::iso_address_claim::MANUFACTURER_CODE)?;
let uid = field_number(line, field::iso_address_claim::UNIQUE_NUMBER)?;
Some(NameKey {
manufacturer_code: u16::try_from(mfr).ok()?,
unique_number: u32::try_from(uid).ok()?,
})
}
fn manufacturer_name_from_claim(line: &Value) -> Option<String> {
let v = field_value(line, field::iso_address_claim::MANUFACTURER_CODE)?;
v.pointer("/name")
.and_then(Value::as_str)
.map(str::to_string)
}
pub(crate) fn field_value(line: &Value, f: FieldRef) -> Option<&Value> {
line.pointer(&format!("/fields/{}", json_pointer_escape(f.field.name)))
}
pub(crate) fn normalize_camel(line: Value) -> Value {
let is_wrapper = matches!(&line, Value::Object(o)
if o.len() == 1 && o.values().next().is_some_and(|v| v.get("pgn").is_some()));
let Value::Object(top) = line else {
return line;
};
let db = PgnDatabase::embedded(Units::Metric);
if !is_wrapper {
let mut record = Value::Object(top);
let pgn = record.get("pgn").and_then(Value::as_u64).unwrap_or(0) as u32;
let info = record
.get("description")
.and_then(Value::as_str)
.and_then(|d| {
db.pgn_variants(pgn)
.find(|p| p.description == d || p.id == d)
})
.or_else(|| db.first_pgn(pgn));
if let Some(info) = info
&& let Some(Value::Object(fields)) = record.get_mut("fields")
{
rekey_fields(fields, info);
}
return record;
}
let (wrapper_id, mut record) = top.into_iter().next().expect("one entry");
let pgn = record.get("pgn").and_then(Value::as_u64).unwrap_or(0) as u32;
if let Some(info) = db.pgn_by_id(&wrapper_id).or_else(|| db.first_pgn(pgn))
&& let Value::Object(obj) = &mut record
{
obj.insert(
"description".to_string(),
Value::String(info.description.to_string()),
);
if let Some(Value::Object(fields)) = obj.get_mut("fields") {
rekey_fields(fields, info);
}
}
record
}
fn rekey_fields(fields: &mut Map<String, Value>, info: &PgnInfo) {
let renames: Vec<(String, String)> = fields
.keys()
.filter_map(|k| {
info.fields
.iter()
.find(|f| f.id == k.as_str() && f.name != k.as_str())
.map(|f| (k.clone(), f.name.to_string()))
})
.collect();
for (old, new) in renames {
if let Some(v) = fields.remove(&old) {
fields.insert(new, v);
}
}
for list_key in ["list", "list2"] {
if let Some(Value::Array(arr)) = fields.get_mut(list_key) {
for elem in arr.iter_mut() {
if let Value::Object(elem_obj) = elem {
rekey_fields(elem_obj, info);
}
}
}
}
}
fn field_number(line: &Value, f: FieldRef) -> Option<i64> {
field_as_int(field_value(line, f)?)
}
fn fill_from_claim(dev: &mut DeviceInfo, line: &Value) {
if dev.manufacturer.is_empty()
&& let Some(name) = field_text(line, field::iso_address_claim::MANUFACTURER_CODE)
{
dev.manufacturer = name;
}
}
fn fill_from_product_info(dev: &mut DeviceInfo, line: &Value) {
if let Some(s) = field_text(line, field::product_information::MODEL_ID) {
dev.model = s;
}
if let Some(s) = field_text(line, field::product_information::SOFTWARE_VERSION_CODE) {
dev.software = s;
}
}
fn fill_from_config_info(dev: &mut DeviceInfo, line: &Value) {
if let Some(s) = field_text(
line,
field::configuration_information::INSTALLATION_DESCRIPTION1,
) {
dev.installation = s;
}
}
fn field_text(line: &Value, f: FieldRef) -> Option<String> {
let v = field_value(line, f)?;
if let Some(s) = v.as_str() {
return Some(s.trim().to_string());
}
if let Some(obj) = v.as_object() {
if let Some(s) = obj.get("name").and_then(Value::as_str) {
return Some(s.trim().to_string());
}
if let Some(n) = obj.get("value") {
return Some(n.to_string());
}
}
if v.is_number() || v.is_boolean() {
return Some(v.to_string());
}
None
}
fn field_as_int(v: &Value) -> Option<i64> {
if let Some(n) = v.as_i64() {
return Some(n);
}
if let Some(obj) = v.as_object()
&& let Some(n) = obj.get("value").and_then(Value::as_i64)
{
return Some(n);
}
if let Some(s) = v.as_str() {
return s.parse().ok();
}
None
}
pub fn variant_for(pgn: u32, mfr: Option<u16>) -> Option<&'static PgnInfo> {
let db = PgnDatabase::embedded(Units::Metric);
if let Some(m) = mfr
&& let Some(v) = db
.pgn_variants(pgn)
.find(|v| v.fields.first().and_then(|f| f.match_value) == Some(m as i64))
{
return Some(v);
}
db.first_pgn(pgn)
}
fn collect_pgn_list(line: &Value) -> Vec<u32> {
let Some(arr) = line.pointer("/fields/list").and_then(Value::as_array) else {
return Vec::new();
};
arr.iter()
.filter_map(|elem| elem.pointer("/PGN").and_then(field_as_int))
.filter_map(|n| u32::try_from(n).ok())
.collect()
}
fn json_pointer_escape(s: &str) -> String {
s.replace('~', "~0").replace('/', "~1")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn state() -> AppState {
AppState::new(Status::new_live("t".into(), 0, 0), HashMap::new())
}
fn report(src: u8, sentence: &str, muted: u8) -> Value {
json!({
"pgn": 262657,
"fields": {"Function": 0, "Source": src, "Sentence": sentence, "Muted": muted}
})
}
#[test]
fn camel_record_normalizes_to_bare_and_reads() {
let mut s = state();
let camel = json!({
"productInformation": {
"prio": 6, "src": 10, "pgn": 126996,
"description": "productInformation",
"fields": {
"modelId": "ACME Radar",
"softwareVersionCode": "1.2.3",
"certificationLevel": 2
}
}
});
s.upsert(126996, 10, None, "productInformation".into(), camel);
let devs = s.device_list();
let dev = devs.iter().find(|d| d.src == 10).expect("device src 10");
assert_eq!(dev.model, "ACME Radar");
assert_eq!(dev.software, "1.2.3");
let entry = s.entries.values().next().expect("one entry");
assert_eq!(entry.description, "Product Information");
assert_eq!(
entry
.line
.pointer("/fields/Model ID")
.and_then(Value::as_str),
Some("ACME Radar")
);
assert!(
entry.line.get("productInformation").is_none(),
"wrapper stripped"
);
}
#[test]
fn flat_camel_record_normalizes_to_bare_and_reads() {
let mut s = state();
let flat = json!({
"prio": 6, "src": 11, "pgn": 126996,
"description": "Product Information",
"fields": {
"modelId": "ACME Radar",
"softwareVersionCode": "1.2.3",
"certificationLevel": 2
}
});
s.upsert(126996, 11, None, "Product Information".into(), flat);
let devs = s.device_list();
let dev = devs.iter().find(|d| d.src == 11).expect("device src 11");
assert_eq!(dev.model, "ACME Radar");
assert_eq!(dev.software, "1.2.3");
let entry = s.entries.values().next().expect("one entry");
assert_eq!(entry.description, "Product Information");
assert_eq!(
entry
.line
.pointer("/fields/Model ID")
.and_then(Value::as_str),
Some("ACME Radar")
);
}
#[test]
fn spaced_name_record_passes_through_untouched() {
let line = json!({
"prio": 6, "src": 12, "pgn": 126996,
"description": "Product Information",
"fields": {"Model ID": "ACME Radar", "Certification Level": 2}
});
assert_eq!(normalize_camel(line.clone()), line);
}
#[test]
fn variant_for_resolves_by_manufacturer() {
assert!(
variant_for(130845, Some(1855))
.unwrap()
.description
.contains("Furuno")
);
assert!(
variant_for(130845, Some(137))
.unwrap()
.description
.contains("Maretron")
);
assert!(variant_for(130845, None).is_some());
assert!(variant_for(130845, Some(9999)).is_some());
}
#[test]
fn camel_repeat_set_element_keys_are_rekeyed() {
let mut s = state();
let camel = json!({
"pgnListTransmitAndReceive": {
"src": 9, "pgn": 126464, "description": "pgnListTransmitAndReceive",
"fields": {"functionCode": 0, "list": [{"pgn": 127250}, {"pgn": 128267}]}
}
});
s.upsert(126464, 9, None, "pgnListTransmitAndReceive".into(), camel);
let lists = s.pgn_lists_for_src(9);
assert_eq!(lists.tx, vec![127250, 128267]);
}
#[test]
fn bare_record_passes_through_untouched() {
let mut s = state();
let bare = json!({
"prio": 6, "src": 7, "pgn": 126996, "description": "Product Information",
"fields": {"Model ID": "Bare Model"}
});
s.upsert(126996, 7, None, "Product Information".into(), bare);
let dev = s.device_list().into_iter().find(|d| d.src == 7).unwrap();
assert_eq!(dev.model, "Bare Model");
}
#[test]
fn nmea0183_report_builds_rows_and_stays_out_of_entries() {
let mut s = state();
s.upsert(262657, 0, None, String::new(), report(35, NMEA0183_ALL, 0));
s.upsert(262657, 0, None, String::new(), report(35, "VHW", 0));
s.upsert(262657, 0, None, String::new(), report(35, "VLW", 1));
assert!(s.entries.is_empty());
assert!(s.device_list().is_empty());
let rows = s.nmea0183_rows();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].src, 35);
assert!(rows[0].sentence.is_none() && !rows[0].muted);
assert_eq!(rows[1].sentence.as_deref(), Some("VHW"));
assert!(!rows[1].muted);
assert_eq!(rows[2].sentence.as_deref(), Some("VLW"));
assert!(rows[2].muted);
}
#[test]
fn whole_source_mute_updates_header() {
let mut s = state();
s.upsert(262657, 0, None, String::new(), report(53, "MWV", 0));
s.upsert(262657, 0, None, String::new(), report(53, NMEA0183_ALL, 1));
let rows = s.nmea0183_rows();
assert!(rows[0].sentence.is_none() && rows[0].muted);
}
fn override_report(src: u8, pgn: u32, interval: u32, mfr: i64, industry: i64) -> Value {
json!({
"pgn": 262658,
"fields": {
"Function": 0, "Source": src, "PGN": pgn, "Interval": interval,
"Manufacturer Code": mfr, "Industry Code": industry
}
})
}
#[test]
fn override_report_populates_overrides_and_stays_out_of_entries() {
let mut s = state();
s.upsert(
262658,
0,
None,
String::new(),
override_report(52, 127250, 100, 0xffff, 0xff),
);
s.upsert(
262658,
0,
None,
String::new(),
override_report(52, 130842, 0, 1855, 4),
);
assert!(s.entries.is_empty());
assert!(s.device_list().is_empty());
let rows = s.override_rows();
assert_eq!(rows.len(), 2);
let heading = rows.iter().find(|r| r.pgn == 127250).unwrap();
assert_eq!(heading.interval_ms, 100);
assert_eq!(heading.manufacturer_code, None);
assert_eq!(heading.industry_code, None);
assert!(!heading.description.is_empty(), "schema description filled");
let prop = rows.iter().find(|r| r.pgn == 130842).unwrap();
assert_eq!(prop.interval_ms, 0);
assert_eq!(prop.manufacturer_code, Some(1855));
assert_eq!(prop.industry_code, Some(4));
}
#[test]
fn override_report_updates_in_place_and_forget_removes() {
let mut s = state();
s.upsert(
262658,
0,
None,
String::new(),
override_report(52, 127250, 100, 0xffff, 0xff),
);
assert_eq!(s.override_rows().len(), 1);
s.upsert(
262658,
0,
None,
String::new(),
override_report(52, 127250, 250, 0xffff, 0xff),
);
let rows = s.override_rows();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].interval_ms, 250);
s.forget_override(52, 127250);
assert!(s.override_rows().is_empty());
}
#[test]
fn pgn_load_rows_aggregate_across_sources_and_sort_busiest_first() {
let mut s = state();
s.upsert(129025, 10, None, "Position".into(), json!({"pgn": 129025}));
s.upsert(129025, 10, None, "Position".into(), json!({"pgn": 129025}));
s.upsert(129025, 11, None, "Position".into(), json!({"pgn": 129025}));
s.upsert(127250, 10, None, "Heading".into(), json!({"pgn": 127250}));
let rows = s.pgn_load_rows();
assert_eq!(rows.len(), 2);
let pos = rows.iter().find(|r| r.pgn == 129025).unwrap();
assert_eq!(pos.sources, 2);
assert_eq!(pos.count, 3);
assert_eq!(pos.description, "Position");
assert_eq!(rows[0].pgn, 129025);
}
#[test]
fn pgn_load_rate_is_count_over_capture_duration() {
let mut s = state();
let ts = |ms: i64| json!({"pgn": 1, "timestamp": crate::engine::format_iso_ms(ms as u64)});
s.upsert(126996, 10, None, "Product".into(), ts(0));
s.upsert(126996, 10, None, "Product".into(), ts(1_000));
s.upsert(126996, 11, None, "Product".into(), ts(0));
s.upsert(126996, 11, None, "Product".into(), ts(1_000));
for k in 0..5 {
s.upsert(127250, 10, None, "Heading".into(), ts(k * 25_000));
}
assert_eq!(s.capture_duration_secs(), 100.0);
let rows = s.pgn_load_rows();
let bursty = rows.iter().find(|r| r.pgn == 126996).unwrap();
let steady = rows.iter().find(|r| r.pgn == 127250).unwrap();
assert!((bursty.rate - 4.0 / 100.0).abs() < 1e-4, "{}", bursty.rate);
assert!((steady.rate - 5.0 / 100.0).abs() < 1e-4, "{}", steady.rate);
assert!(steady.rate > bursty.rate);
}
}