use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::engine::RawFrame;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub const PGN_NMEA0183_FILTER: u32 = 262657;
pub const FILTER_FN_REPORT: u8 = 0;
pub const FILTER_FN_SET: u8 = 1;
pub const FILTER_FN_REQUEST: u8 = 2;
pub const FILTER_REPORT_INTERVAL: Duration = Duration::from_secs(2);
pub fn is_set_frame(frame: &RawFrame) -> bool {
frame.pgn == PGN_NMEA0183_FILTER && frame.data.first() == Some(&FILTER_FN_SET)
}
pub fn is_request_frame(frame: &RawFrame) -> bool {
frame.pgn == PGN_NMEA0183_FILTER && frame.data.first() == Some(&FILTER_FN_REQUEST)
}
pub const ALL_SENTENCES: &str = "ALL";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NameKey(pub u64);
impl NameKey {
fn to_hex(self) -> String {
format!("{:016x}", self.0)
}
fn from_hex(s: &str) -> Option<Self> {
u64::from_str_radix(s.trim_start_matches("0x"), 16)
.ok()
.map(NameKey)
}
}
pub enum Decision<'a> {
DropAll,
Keep(&'a [String]),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DeviceRule {
#[serde(default)]
pub muted: bool,
#[serde(default)]
pub muted_sentences: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DeviceEntry {
name: String,
#[serde(flatten)]
rule: DeviceRule,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct FileFormat {
#[serde(default)]
devices: Vec<DeviceEntry>,
}
pub struct FilterReportRow {
pub src: u8,
pub sentence: String,
pub muted: bool,
}
pub struct NmeaFilter {
path: PathBuf,
src_name: [Option<NameKey>; 256],
rules: HashMap<NameKey, DeviceRule>,
observed: HashMap<u8, BTreeSet<String>>,
}
impl NmeaFilter {
pub fn load(path: &Path) -> Result<Self> {
let rules = match std::fs::read_to_string(path) {
Ok(body) => {
let file: FileFormat = serde_json::from_str(&body).with_context(|| {
format!("parsing NMEA 0183 filter config {}", path.display())
})?;
file.devices
.into_iter()
.filter_map(|e| NameKey::from_hex(&e.name).map(|k| (k, e.rule)))
.collect()
}
Err(_) => HashMap::new(),
};
Ok(Self {
path: path.to_path_buf(),
src_name: [None; 256],
rules,
observed: HashMap::new(),
})
}
fn save(&self) -> Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let mut devices: Vec<DeviceEntry> = self
.rules
.iter()
.map(|(k, rule)| DeviceEntry {
name: k.to_hex(),
rule: rule.clone(),
})
.collect();
devices.sort_by(|a, b| a.name.cmp(&b.name));
let body = serde_json::to_string_pretty(&FileFormat { devices })
.context("serialising NMEA 0183 filter config")?;
std::fs::write(&self.path, body).with_context(|| format!("writing {}", self.path.display()))
}
pub fn note_address_claim(&mut self, src: u8, name: u64) {
self.src_name[src as usize] = Some(NameKey(name));
}
pub fn decide(&self, src: u8) -> Decision<'_> {
if self.rules.is_empty() {
return Decision::Keep(&[]);
}
let Some(name) = self.src_name[src as usize] else {
return Decision::DropAll;
};
match self.rules.get(&name) {
Some(rule) if rule.muted => Decision::DropAll,
Some(rule) => Decision::Keep(&rule.muted_sentences),
None => Decision::Keep(&[]),
}
}
pub fn apply(&mut self, src: u8, buf: &mut String) {
for line in buf.split_inclusive('\n') {
if let Some(fmt) = sentence_formatter(line)
&& !self.observed.get(&src).is_some_and(|s| s.contains(fmt))
{
self.observed
.entry(src)
.or_default()
.insert(fmt.to_string());
}
}
match self.decide(src) {
Decision::DropAll => buf.clear(),
Decision::Keep(muted) => {
if muted.is_empty() {
return;
}
let mut kept = String::with_capacity(buf.len());
for line in buf.split_inclusive('\n') {
match sentence_formatter(line) {
Some(fmt) if muted.iter().any(|m| m == fmt) => {}
_ => kept.push_str(line),
}
}
*buf = kept;
}
}
}
pub fn set(&mut self, src: u8, sentence: &str, muted: bool) -> bool {
let Some(name) = self.src_name[src as usize] else {
log::debug!("PGN 262657 Set for src {src} ignored: no learned NAME");
return false;
};
let rule = self.rules.entry(name).or_default();
if sentence == ALL_SENTENCES {
rule.muted = muted;
} else if muted {
if !rule.muted_sentences.iter().any(|s| s == sentence) {
rule.muted_sentences.push(sentence.to_string());
}
} else {
rule.muted_sentences.retain(|s| s != sentence);
}
if !rule.muted && rule.muted_sentences.is_empty() {
self.rules.remove(&name);
}
if let Err(e) = self.save() {
log::warn!("failed to persist NMEA 0183 filter change: {e:#}");
}
true
}
pub fn report(&self) -> Vec<FilterReportRow> {
let mut rows = Vec::new();
let mut srcs: Vec<u8> = self.observed.keys().copied().collect();
srcs.sort_unstable();
for src in srcs {
let source_muted = matches!(self.decide(src), Decision::DropAll);
rows.push(FilterReportRow {
src,
sentence: ALL_SENTENCES.to_string(),
muted: source_muted,
});
if let Some(formatters) = self.observed.get(&src) {
for fmt in formatters {
let muted = source_muted
|| matches!(self.decide(src), Decision::Keep(m) if m.iter().any(|s| s == fmt));
rows.push(FilterReportRow {
src,
sentence: fmt.clone(),
muted,
});
}
}
}
rows
}
pub fn apply_set_frame(&mut self, data: &[u8]) {
if data.len() < 6 {
return;
}
let src = data[1];
let sentence = std::str::from_utf8(&data[2..5])
.unwrap_or("")
.trim_matches(|c| c == '\0' || c == ' ');
if sentence.is_empty() {
return;
}
self.set(src, sentence, data[5] != 0);
}
pub fn report_frames(&self, timestamp: Option<String>) -> Vec<RawFrame> {
self.report()
.into_iter()
.map(|row| {
let b = row.sentence.as_bytes();
let data = [
FILTER_FN_REPORT,
row.src,
*b.first().unwrap_or(&b' '),
*b.get(1).unwrap_or(&b' '),
*b.get(2).unwrap_or(&b' '),
u8::from(row.muted),
0xff,
0xff,
];
RawFrame::new(timestamp.clone(), 7, PGN_NMEA0183_FILTER, 0, 255, data)
})
.collect()
}
}
fn sentence_formatter(line: &str) -> Option<&str> {
let s = line.strip_prefix('$')?;
s.get(2..5)
}
#[cfg(test)]
mod tests {
use super::*;
const NAME_SPEED: u64 = 0x0004_0000_1066_7913;
const NAME_AIRMAR: u64 = 0x0004_0000_0761_9208;
fn filter_with(rules: &[(u64, DeviceRule)]) -> NmeaFilter {
let mut f = NmeaFilter {
path: PathBuf::from("/dev/null"),
src_name: [None; 256],
rules: HashMap::new(),
observed: HashMap::new(),
};
for (name, rule) in rules {
f.rules.insert(NameKey(*name), rule.clone());
}
f
}
#[test]
fn no_name_drops_everything_once_a_rule_exists() {
let mut f = filter_with(&[(
NAME_SPEED,
DeviceRule {
muted: true,
..Default::default()
},
)]);
let mut buf = "$CBVHW,,T,,M,0.0,N,0.0,K*54\r\n".to_string();
f.apply(33, &mut buf); assert!(buf.is_empty());
}
#[test]
fn empty_filter_passes_everything() {
let mut f = filter_with(&[]);
let mut buf = "$CBVHW,,T,,M,0.0,N,0.0,K*54\r\n".to_string();
f.apply(33, &mut buf);
assert_eq!(buf, "$CBVHW,,T,,M,0.0,N,0.0,K*54\r\n");
}
#[test]
fn known_name_no_rule_passes() {
let mut f = filter_with(&[]);
f.note_address_claim(33, NAME_SPEED);
let mut buf = "$CBVHW,,T,,M,0.0,N,0.0,K*54\r\n".to_string();
f.apply(33, &mut buf);
assert_eq!(buf, "$CBVHW,,T,,M,0.0,N,0.0,K*54\r\n");
}
#[test]
fn muted_source_drops_everything() {
let mut f = filter_with(&[(
NAME_SPEED,
DeviceRule {
muted: true,
..Default::default()
},
)]);
f.note_address_claim(33, NAME_SPEED);
let mut buf = "$CBVHW,,T,,M,0.0,N,0.0,K*54\r\n".to_string();
f.apply(33, &mut buf);
assert!(buf.is_empty());
}
#[test]
fn muted_sentence_drops_only_that_formatter() {
let mut f = filter_with(&[(
NAME_AIRMAR,
DeviceRule {
muted: false,
muted_sentences: vec!["VLW".to_string()],
},
)]);
f.note_address_claim(35, NAME_AIRMAR);
let mut buf =
"$CDVHW,,T,,M,4.6,N,8.6,K*5E\r\n$CDVLW,14467.8,N,14467.8,N*4A\r\n$CDMTW,17.0,C*01\r\n"
.to_string();
f.apply(35, &mut buf);
assert_eq!(buf, "$CDVHW,,T,,M,4.6,N,8.6,K*5E\r\n$CDMTW,17.0,C*01\r\n");
}
#[test]
fn degenerate_name_is_still_keyed() {
let degenerate: u64 = 0xffff_ff00_ffe0_ffff;
let mut f = filter_with(&[]);
f.path = std::env::temp_dir().join("canboat-nmea-filter-degen.json");
f.note_address_claim(53, degenerate);
let mut buf = "$DFMWV,308.3,R,20.9,K,A*09\r\n".to_string();
f.apply(53, &mut buf);
assert_eq!(buf, "$DFMWV,308.3,R,20.9,K,A*09\r\n"); assert!(f.set(53, ALL_SENTENCES, true)); let mut buf2 = "$DFMWV,308.3,R,20.9,K,A*09\r\n".to_string();
f.apply(53, &mut buf2);
assert!(buf2.is_empty());
}
#[test]
fn set_by_source_resolves_to_name_and_reports() {
let mut f = filter_with(&[]);
f.path = std::env::temp_dir().join("canboat-nmea-filter-test.json");
f.note_address_claim(35, NAME_AIRMAR);
let mut buf = "$CDVHW,x*01\r\n$CDVLW,y*02\r\n$CDMTW,z*03\r\n".to_string();
f.apply(35, &mut buf);
assert!(f.set(35, "VLW", true));
let mut buf2 = "$CDVHW,x*01\r\n$CDVLW,y*02\r\n$CDMTW,z*03\r\n".to_string();
f.apply(35, &mut buf2);
assert_eq!(buf2, "$CDVHW,x*01\r\n$CDMTW,z*03\r\n");
let rows = f.report();
let vlw = rows
.iter()
.find(|r| r.src == 35 && r.sentence == "VLW")
.unwrap();
assert!(vlw.muted);
let vhw = rows
.iter()
.find(|r| r.src == 35 && r.sentence == "VHW")
.unwrap();
assert!(!vhw.muted);
assert!(
rows.iter()
.any(|r| r.src == 35 && r.sentence == ALL_SENTENCES && !r.muted)
);
}
#[test]
fn set_on_unknown_source_is_noop() {
let mut f = filter_with(&[]);
f.path = std::env::temp_dir().join("canboat-nmea-filter-noop.json");
assert!(!f.set(99, ALL_SENTENCES, true)); }
#[test]
fn set_frame_applies_and_report_frames_reflect_it() {
let mut f = filter_with(&[]);
f.path = std::env::temp_dir().join("canboat-nmea-filter-setframe.json");
f.note_address_claim(35, NAME_AIRMAR);
let mut buf = "$CDVHW,,T,,M,4.6,N,8.6,K*5E\r\n".to_string();
f.apply(35, &mut buf);
assert_eq!(buf, "$CDVHW,,T,,M,4.6,N,8.6,K*5E\r\n");
f.apply_set_frame(&[FILTER_FN_SET, 35, b'V', b'H', b'W', 1]);
let mut buf = "$CDVHW,,T,,M,4.6,N,8.6,K*5E\r\n".to_string();
f.apply(35, &mut buf);
assert!(buf.is_empty(), "VHW should now be muted");
let frames = f.report_frames(Some("2026-07-09T00:00:00.000Z".into()));
assert!(
frames.iter().all(|fr| fr.timestamp.is_some()),
"report frames must carry a timestamp"
);
assert!(
frames.iter().any(|fr| fr.pgn == PGN_NMEA0183_FILTER
&& fr.data.len() >= 6
&& fr.data[0] == FILTER_FN_REPORT
&& fr.data[1] == 35
&& &fr.data[2..5] == b"VHW"
&& fr.data[5] == 1),
"expected a muted VHW report row for src 35, got {frames:?}"
);
}
#[test]
fn is_set_frame_matches_only_the_set_function() {
let set = RawFrame::new(
None,
7,
PGN_NMEA0183_FILTER,
0,
255,
[FILTER_FN_SET, 35, b'V', b'H', b'W', 1, 0xff, 0xff],
);
assert!(is_set_frame(&set));
let report = RawFrame::new(
None,
7,
PGN_NMEA0183_FILTER,
0,
255,
[FILTER_FN_REPORT, 35, b'V', b'H', b'W', 1, 0xff, 0xff],
);
assert!(!is_set_frame(&report), "a Report is not a Set");
let other = RawFrame::new(None, 6, 127251, 5, 255, [0u8; 8]);
assert!(!is_set_frame(&other), "a non-262657 PGN is not a Set");
}
#[test]
fn formatter_extraction() {
assert_eq!(sentence_formatter("$CBVHW,x*54\r\n"), Some("VHW"));
assert_eq!(sentence_formatter("$AARSA,2.0*6B\r\n"), Some("RSA"));
assert_eq!(sentence_formatter("!AIVDM,1*7B\r\n"), None);
assert_eq!(sentence_formatter("$AB\r\n"), None);
}
}