use crate::engine::RawFrame;
use crate::engine::format::ikonvert::{
self, IkonvertLine, TX_LIMIT_OFF, TX_OFFLINE, TX_ONLINE_ALL, TX_ONLINE_NORMAL,
synthesize_network_status,
};
use crate::engine::pgn_list::{self, PgnListStatus, PgnListSupport, PgnLists, TxGate};
use super::{Codec, Event, Refused, SYNTHETIC_PGN_START};
use crate::engine::format_iso_ms;
#[derive(Debug, Clone, Default)]
pub struct Config {
pub rx_list: Option<String>,
pub tx_list: Option<String>,
pub pgn_lists: PgnLists,
pub extra_tx_pgns: Vec<u32>,
pub rate_limit_off: bool,
pub skip_init: bool,
}
pub fn pgn_list_status(lists: &PgnLists) -> PgnListStatus {
let (_, mut dropped) = pgn_list::merge(&[], &lists.tx);
for pgn in pgn_list::merge(&[], &lists.rx).1 {
if !dropped.contains(&pgn) {
dropped.push(pgn);
}
}
let written = |list: &[u32]| {
if list.is_empty() {
PgnListSupport::Untouched
} else {
PgnListSupport::Pushed
}
};
PgnListStatus {
tx: written(&lists.tx),
rx: written(&lists.rx),
dropped,
}
}
struct Init {
rx_list: Option<String>,
tx_list: Option<String>,
normal_mode: bool,
rate_limit_off: bool,
}
impl Init {
fn new(config: &Config) -> Self {
let named_tx =
!pgns_in(config.tx_list.as_deref()).is_empty() || !config.pgn_lists.tx.is_empty();
let extra_tx: &[u32] = if named_tx { &config.extra_tx_pgns } else { &[] };
Self {
rx_list: list_sentence(config.rx_list.as_deref(), &config.pgn_lists.rx, "RX"),
tx_list: list_sentence(
config.tx_list.as_deref(),
&[config.pgn_lists.tx.as_slice(), extra_tx].concat(),
"TX",
),
normal_mode: config.rx_list.is_some(),
rate_limit_off: config.rate_limit_off,
}
}
}
fn list_sentence(list: Option<&str>, extra: &[u32], which: &str) -> Option<String> {
let mut pgns: Vec<u32> = Vec::new();
for item in list.unwrap_or("").split(',').map(str::trim) {
match item.parse() {
Ok(pgn) => pgns.push(pgn),
Err(_) if item.is_empty() => {}
Err(_) => log::warn!("ikonvert: ignoring {which} list entry {item:?}, not a PGN"),
}
}
pgns.extend_from_slice(extra);
let (pgns, dropped) = pgn_list::merge(&[], &pgns);
if !dropped.is_empty() {
log::warn!("ikonvert: leaving {which} PGNs {dropped:?} out (invalid, or the list is full)");
}
if pgns.is_empty() {
return None;
}
Some(
pgns.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(","),
)
}
const MAX_LINE: usize = 4096;
pub struct Ikonvert {
acc: String,
init: Init,
init_state: u32,
skip_init: bool,
warned_off_bus: bool,
gate: Option<TxGate>,
}
impl Ikonvert {
pub fn new(config: Config) -> Self {
let init_state = if config.skip_init {
STATE_DONE
} else {
STATE_WAIT_OFFLINE_ACK
};
Self {
acc: String::with_capacity(1024),
init: Init::new(&config),
init_state,
skip_init: config.skip_init,
warned_off_bus: false,
gate: tx_gate(&config),
}
}
}
impl Codec for Ikonvert {
fn open(&mut self) -> Vec<u8> {
if self.skip_init {
return Vec::new();
}
log::info!("ikonvert: initialization start");
TX_OFFLINE.as_bytes().to_vec()
}
fn receive(&mut self, bytes: &[u8], now_ms: u64, events: &mut Vec<Event>) {
self.acc.push_str(&String::from_utf8_lossy(bytes));
while let Some(eol) = self.acc.find('\n') {
let line: String = self.acc.drain(..=eol).collect();
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.is_empty() {
continue;
}
match ikonvert::parse_line(trimmed) {
Ok(IkonvertLine::Frame(mut f)) => {
f.timestamp = Some(format_iso_ms(now_ms));
events.push(Event::Frame(f));
}
Ok(IkonvertLine::Control(c)) => self.handle_control(&c, now_ms, events),
Ok(IkonvertLine::Other) => {}
Err(e) => {
if let Some(idx) = trimmed.find("$PDGY,TEXT,") {
let banner = &trimmed[idx + "$PDGY,".len()..];
log::warn!("ikonvert: corrupt line, but found TEXT banner");
self.handle_control(banner, now_ms, events);
} else {
events.push(Event::Error(format!("ikonvert: {e}")));
}
}
}
}
if self.acc.len() > MAX_LINE {
self.acc.clear();
events.push(Event::Error(format!(
"ikonvert: no line end in {MAX_LINE} bytes, discarded"
)));
}
}
fn close(&mut self) -> Vec<u8> {
if self.skip_init {
return Vec::new();
}
log::info!("ikonvert: send N2NET_OFFLINE");
TX_OFFLINE.as_bytes().to_vec()
}
fn send(&mut self, frame: &RawFrame) -> Result<Vec<u8>, Refused> {
if frame.pgn >= SYNTHETIC_PGN_START {
log::debug!("ikonvert: skipping synthetic PGN {}", frame.pgn);
return Err(Refused::Synthetic);
}
if let Some(gate) = &self.gate
&& !gate.allows(frame.pgn)
{
return Err(Refused::NotOnTxList);
}
Ok(ikonvert::encode_tx_frame(frame).into_bytes())
}
}
impl Ikonvert {
fn handle_control(&mut self, body: &str, now_ms: u64, events: &mut Vec<Event>) {
if let Some(rest) = body.strip_prefix("TEXT,") {
log::info!("ikonvert: connected to {rest}");
self.advance_after_text(events);
return;
}
if let Some(rest) = body.strip_prefix("ACK,") {
log::debug!("ikonvert: ACK ({rest})");
self.advance_after_ack(events);
return;
}
if let Some(rest) = body.strip_prefix("NAK,") {
log::warn!("ikonvert: NAK ({rest})");
return;
}
if body.starts_with("000000,") {
match synthesize_network_status(body, format_iso_ms(now_ms)) {
Some(frame) => {
log::debug!(
"ikonvert: synthesized network status (load={} count={})",
frame.data[0],
frame.data[5],
);
events.push(Event::Frame(frame));
}
None if self.skip_init && !self.warned_off_bus => {
self.warned_off_bus = true;
log::warn!(
"ikonvert: the gateway is off the bus and sends no PGNs until a \
client puts it on (N2NET_INIT), which canboat can't over udp://; \
use tcp://"
);
}
None => log::debug!("ikonvert: keep-alive heartbeat"),
}
self.maybe_resend_offline(events);
return;
}
log::debug!("ikonvert control: {body}");
self.maybe_resend_offline(events);
}
fn advance_after_text(&mut self, events: &mut Vec<Event>) {
match self.init_state {
STATE_WAIT_OFFLINE_ACK => {
self.init_state = STATE_SEND_RESET;
if let Some(cmd) = next_init_command(&mut self.init_state, &self.init) {
events.push(Event::Send(cmd.into_bytes()));
} else {
log::info!("ikonvert: initialization complete");
}
}
STATE_DONE if self.skip_init => {
self.warned_off_bus = false;
}
STATE_DONE => {
log::warn!("ikonvert: device reset detected, re-initialising");
self.init_state = STATE_SEND_RESET;
if let Some(cmd) = next_init_command(&mut self.init_state, &self.init) {
events.push(Event::Send(cmd.into_bytes()));
}
}
state => {
log::debug!("ikonvert: TEXT during init at state {state}");
}
}
}
fn advance_after_ack(&mut self, events: &mut Vec<Event>) {
let state = self.init_state;
if state == STATE_DONE || state == STATE_WAIT_OFFLINE_ACK || state.is_multiple_of(2) {
return;
}
self.init_state -= 1;
if let Some(cmd) = next_init_command(&mut self.init_state, &self.init) {
events.push(Event::Send(cmd.into_bytes()));
} else {
log::info!("ikonvert: initialization complete");
}
}
fn maybe_resend_offline(&self, events: &mut Vec<Event>) {
if self.init_state != STATE_WAIT_OFFLINE_ACK {
return;
}
log::debug!("ikonvert: resending N2NET_OFFLINE");
events.push(Event::Send(TX_OFFLINE.as_bytes().to_vec()));
}
}
fn tx_gate(config: &Config) -> Option<TxGate> {
if config.skip_init {
return None;
}
let mut named = pgns_in(config.tx_list.as_deref());
named.extend_from_slice(&config.pgn_lists.tx);
TxGate::new("ikonvert", &named, &config.extra_tx_pgns)
}
fn pgns_in(list: Option<&str>) -> Vec<u32> {
list.unwrap_or("")
.split(',')
.filter_map(|p| p.trim().parse().ok())
.collect()
}
const STATE_DONE: u32 = 0;
const STATE_MAYBE_LIMIT_OFF: u32 = 2;
const STATE_WAIT_INIT_ACK: u32 = 3;
const STATE_SEND_INIT: u32 = 4;
const STATE_MAYBE_SHOWLISTS: u32 = 6;
const STATE_MAYBE_TX_LIST: u32 = 8;
const STATE_WAIT_TX_LIST_ACK: u32 = 7;
const STATE_MAYBE_RX_LIST: u32 = 10;
const STATE_WAIT_RX_LIST_ACK: u32 = 9;
const STATE_SEND_RESET: u32 = 12;
const STATE_WAIT_RESET_ACK: u32 = 11;
const STATE_WAIT_OFFLINE_ACK: u32 = 13;
fn next_init_command(state: &mut u32, init: &Init) -> Option<String> {
loop {
match *state {
STATE_SEND_RESET => {
*state = STATE_WAIT_RESET_ACK;
log::info!("ikonvert: send N2NET_RESET");
return Some("$PDGY,N2NET_RESET\r\n".to_string());
}
STATE_MAYBE_RX_LIST => {
if let Some(list) = &init.rx_list {
*state = STATE_WAIT_RX_LIST_ACK;
log::info!("ikonvert: send RX_LIST {list}");
return Some(format!("$PDGY,RX_LIST,{list}\r\n"));
}
*state = STATE_MAYBE_TX_LIST;
}
STATE_MAYBE_TX_LIST => {
if let Some(list) = &init.tx_list {
*state = STATE_WAIT_TX_LIST_ACK;
log::info!("ikonvert: send TX_LIST {list}");
return Some(format!("$PDGY,TX_LIST,{list}\r\n"));
}
*state = STATE_MAYBE_SHOWLISTS;
}
STATE_MAYBE_SHOWLISTS => {
*state = STATE_SEND_INIT;
}
STATE_SEND_INIT => {
*state = STATE_WAIT_INIT_ACK;
let cmd = if init.normal_mode {
TX_ONLINE_NORMAL
} else {
TX_ONLINE_ALL
};
log::info!("ikonvert: send N2NET_INIT");
return Some(cmd.to_string());
}
STATE_MAYBE_LIMIT_OFF => {
let cmd = if init.rate_limit_off {
log::info!("ikonvert: send TX_LIMIT,OFF");
Some(TX_LIMIT_OFF.to_string())
} else {
None
};
*state = STATE_DONE;
return cmd;
}
STATE_DONE => return None,
_ => return None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: u64 = 1_780_082_164_826;
fn run_init(config: Config) -> Vec<String> {
let mut codec = Ikonvert::new(config);
let first = String::from_utf8(codec.open()).unwrap();
let mut commands = vec![first];
let mut first_response = true;
loop {
let mut events = Vec::new();
if first_response {
codec.handle_control("TEXT,iKonvert_simulated", NOW, &mut events);
first_response = false;
} else {
codec.handle_control("ACK,test", NOW, &mut events);
}
let mut emitted_one = false;
for ev in events {
if let Event::Send(b) = ev {
commands.push(String::from_utf8(b).unwrap());
emitted_one = true;
}
}
if !emitted_one {
break;
}
}
commands
}
#[test]
fn frames_are_stamped_with_the_callers_time() {
let mut codec = Ikonvert::new(Config {
skip_init: true,
..Config::default()
});
let mut events = Vec::new();
codec.receive(b"!PDGY,127257,3,35,255,12.3", NOW, &mut events);
assert!(events.is_empty(), "half a line must not emit");
codec.receive(b"45,AQID\r\n", NOW, &mut events);
let [Event::Frame(f)] = &events[..] else {
panic!("expected one frame, got {events:?}")
};
assert_eq!(f.pgn, 127257);
assert_eq!(f.src, 35);
assert_eq!(&f.data[..], &[1, 2, 3]);
assert_eq!(f.timestamp.as_deref(), Some("2026-05-29T19:16:04.826Z"));
}
#[test]
fn a_receive_only_link_sends_nothing() {
let mut codec = Ikonvert::new(Config {
skip_init: true,
..Config::default()
});
assert!(codec.open().is_empty());
let mut events = Vec::new();
codec.receive(
b"$PDGY,TEXT,Digital_Yacht_NavLink 2_v2.22_#AB-181018-12345_Mode:15\r\n\
$PDGY,000000,,,,,,,\r\n\
!PDGY,127250,2,3,255,481.734,2C20AAAAAAE=\r\n\
$PDGY,000000,4,,5,482,1,0\r\n",
NOW,
&mut events,
);
assert!(
!events.iter().any(|e| matches!(e, Event::Send(_))),
"{events:?}"
);
let pgns: Vec<u32> = events
.iter()
.filter_map(|e| match e {
Event::Frame(f) => Some(f.pgn),
_ => None,
})
.collect();
assert_eq!(pgns, [127250, 0x40100]); assert!(codec.close().is_empty());
}
#[test]
fn endless_bytes_without_a_line_end_are_dropped() {
let mut codec = Ikonvert::new(Config {
skip_init: true,
..Config::default()
});
let mut events = Vec::new();
codec.receive(&vec![b'x'; 3 * MAX_LINE], NOW, &mut events);
assert!(matches!(&events[..], [Event::Error(_)]), "{events:?}");
assert!(codec.acc.is_empty());
events.clear();
codec.receive(b"!PDGY,127257,3,35,255,12.345,AQID\r\n", NOW, &mut events);
assert!(matches!(&events[..], [Event::Frame(_)]), "{events:?}");
}
#[test]
fn default_init_sends_offline_reset_init_all() {
let cmds = run_init(Config::default());
assert_eq!(
cmds,
vec![
"$PDGY,N2NET_OFFLINE\r\n".to_string(),
"$PDGY,N2NET_RESET\r\n".to_string(),
"$PDGY,N2NET_INIT,ALL\r\n".to_string(),
],
"default init should be OFFLINE → RESET → INIT,ALL"
);
}
#[test]
fn rx_list_triggers_reset_rxlist_normal() {
let cfg = Config {
rx_list: Some("129025,129026".to_string()),
..Config::default()
};
let cmds = run_init(cfg);
assert_eq!(
cmds,
vec![
"$PDGY,N2NET_OFFLINE\r\n".to_string(),
"$PDGY,N2NET_RESET\r\n".to_string(),
"$PDGY,RX_LIST,129025,129026\r\n".to_string(),
"$PDGY,N2NET_INIT,NORMAL\r\n".to_string(),
]
);
}
#[test]
fn pgn_lists_fill_both_lists_without_filtering() {
let cfg = Config {
pgn_lists: PgnLists {
tx: vec![127508, 127506],
rx: vec![129025],
},
..Config::default()
};
assert_eq!(
run_init(cfg),
vec![
"$PDGY,N2NET_OFFLINE\r\n".to_string(),
"$PDGY,N2NET_RESET\r\n".to_string(),
"$PDGY,RX_LIST,129025\r\n".to_string(),
"$PDGY,TX_LIST,127508,127506\r\n".to_string(),
"$PDGY,N2NET_INIT,ALL\r\n".to_string(),
]
);
}
#[test]
fn pgn_lists_extend_the_legacy_filters() {
let cfg = Config {
rx_list: Some("129025, 129026".to_string()),
tx_list: Some("127508".to_string()),
pgn_lists: PgnLists {
tx: vec![127508, 127506, 0x40000],
rx: vec![129026, 130306],
},
..Config::default()
};
assert_eq!(
run_init(cfg),
vec![
"$PDGY,N2NET_OFFLINE\r\n".to_string(),
"$PDGY,N2NET_RESET\r\n".to_string(),
"$PDGY,RX_LIST,129025,129026,130306\r\n".to_string(),
"$PDGY,TX_LIST,127508,127506\r\n".to_string(),
"$PDGY,N2NET_INIT,NORMAL\r\n".to_string(),
]
);
}
fn frame(pgn: u32) -> RawFrame {
RawFrame {
timestamp: None,
prio: 6,
pgn,
src: 0,
dst: 255,
data: vec![0; 8].into(),
}
}
fn encoder_for(config: &Config) -> Ikonvert {
Ikonvert::new(config.clone())
}
#[test]
fn a_named_transmit_list_refuses_the_rest() {
let mut encoder = encoder_for(&Config {
tx_list: Some("130306".to_string()),
pgn_lists: PgnLists {
tx: vec![127508],
rx: vec![],
},
extra_tx_pgns: vec![127258],
..Config::default()
});
for pgn in [127508, 130306, 127258, 59904, 126996] {
assert!(encoder.send(&frame(pgn)).is_ok(), "{pgn} allowed");
}
assert_eq!(
encoder.send(&frame(127506)),
Err(Refused::NotOnTxList),
"not listed"
);
assert_eq!(encoder.send(&frame(0x40100)), Err(Refused::Synthetic));
}
#[test]
fn without_a_named_list_nothing_is_refused() {
for config in [
Config {
extra_tx_pgns: vec![127258],
..Config::default()
},
Config {
pgn_lists: PgnLists {
tx: vec![127508],
rx: vec![],
},
skip_init: true,
..Config::default()
},
] {
assert!(encoder_for(&config).send(&frame(127506)).is_ok());
}
}
#[test]
fn extra_pgns_join_the_tx_list() {
let cmds = run_init(Config {
pgn_lists: PgnLists {
tx: vec![127508],
rx: vec![],
},
extra_tx_pgns: vec![127258],
..Config::default()
});
assert!(
cmds.contains(&"$PDGY,TX_LIST,127508,127258\r\n".to_string()),
"{cmds:?}"
);
}
#[test]
fn extra_pgns_alone_write_no_tx_list() {
let cmds = run_init(Config {
extra_tx_pgns: vec![127258],
..Config::default()
});
assert!(
!cmds.iter().any(|c| c.starts_with("$PDGY,TX_LIST")),
"{cmds:?}"
);
}
#[test]
fn the_gateway_holds_both_lists() {
let status = pgn_list_status(&PgnLists {
tx: vec![127508, 0x40000],
rx: vec![],
});
assert_eq!(status.tx, PgnListSupport::Pushed);
assert_eq!(status.rx, PgnListSupport::Untouched, "nothing named");
assert_eq!(status.dropped, [0x40000]);
}
#[test]
fn rate_limit_off_sends_after_init_with_no_ack() {
let cfg = Config {
rate_limit_off: true,
..Config::default()
};
let cmds = run_init(cfg);
assert_eq!(
cmds,
vec![
"$PDGY,N2NET_OFFLINE\r\n".to_string(),
"$PDGY,N2NET_RESET\r\n".to_string(),
"$PDGY,N2NET_INIT,ALL\r\n".to_string(),
"$PDGY,TX_LIMIT,OFF\r\n".to_string(),
]
);
}
#[test]
fn extra_ack_after_done_is_ignored() {
let mut decoder = Ikonvert::new(Config::default());
let mut events = Vec::new();
decoder.handle_control("TEXT,welcome", NOW, &mut events); events.clear();
decoder.handle_control("ACK,reset", NOW, &mut events); events.clear();
decoder.handle_control("ACK,init", NOW, &mut events); events.clear();
decoder.handle_control("ACK,stray", NOW, &mut events); assert!(events.is_empty(), "post-init ACKs must not emit commands");
}
#[test]
fn closing_sends_offline() {
let mut live = Ikonvert::new(Config::default());
assert_eq!(live.close(), b"$PDGY,N2NET_OFFLINE\r\n");
let mut replay = Ikonvert::new(Config {
skip_init: true,
..Config::default()
});
assert!(replay.open().is_empty());
assert!(replay.close().is_empty());
}
#[test]
fn heartbeat_at_offline_wait_resends_offline() {
let mut decoder = Ikonvert::new(Config::default());
let mut events = Vec::new();
decoder.handle_control("000000,,,,,,", NOW, &mut events);
assert_eq!(events.len(), 1, "heartbeat should trigger one resend");
match &events[0] {
Event::Send(b) => {
assert_eq!(String::from_utf8_lossy(b), "$PDGY,N2NET_OFFLINE\r\n");
}
_ => panic!("expected SendBytes"),
}
}
#[test]
fn populated_heartbeat_emits_synthetic_frame() {
let mut decoder = Ikonvert::new(Config::default());
let mut events = Vec::new();
decoder.handle_control("TEXT,welcome", NOW, &mut events); events.clear();
decoder.handle_control("ACK,reset", NOW, &mut events); events.clear();
decoder.handle_control("ACK,init", NOW, &mut events); events.clear();
decoder.handle_control("000000,38,1,38,753,2,0", NOW, &mut events);
assert_eq!(events.len(), 1, "data heartbeat should emit one frame");
match &events[0] {
Event::Frame(f) => {
assert_eq!(f.pgn, 0x40100); assert_eq!(f.prio, 7);
assert_eq!(f.src, 2, "src = gateway address from heartbeat");
assert_eq!(f.dst, 255);
assert_eq!(f.data.len(), 15);
assert_eq!(f.data[0], 38, "CAN load");
assert_eq!(&f.data[6..10], &[0xf1, 0x02, 0, 0], "uptime LE = 753");
}
other => panic!("expected Frame, got {other:?}"),
}
}
#[test]
fn text_after_init_triggers_reinit() {
let mut decoder = Ikonvert::new(Config::default());
let mut events = Vec::new();
decoder.handle_control("TEXT,banner", NOW, &mut events); events.clear();
decoder.handle_control("ACK,reset", NOW, &mut events); events.clear();
decoder.handle_control("ACK,init", NOW, &mut events); events.clear();
decoder.handle_control("TEXT,banner2", NOW, &mut events);
assert_eq!(events.len(), 1, "post-init TEXT should re-init");
match &events[0] {
Event::Send(b) => {
let s = String::from_utf8_lossy(b);
assert!(
s.starts_with("$PDGY,N2NET_RESET"),
"expected re-init to start with RESET, got {s:?}"
);
}
_ => panic!("expected SendBytes"),
}
}
#[test]
fn corrupt_line_with_embedded_text_still_resets() {
let mut decoder = Ikonvert::new(Config::default());
let mut events = Vec::new();
decoder.handle_control("TEXT,init", NOW, &mut events); events.clear();
decoder.handle_control("ACK,reset", NOW, &mut events); events.clear();
decoder.handle_control("ACK,init", NOW, &mut events); events.clear();
let bad = b"!PDGY,65350,2,27,\xff$PDGY,TEXT,Digital_Yacht_iKonvert_v2_49_x\r\n";
decoder.receive(bad, NOW, &mut events);
let send_count = events
.iter()
.filter(|e| matches!(e, Event::Send(_)))
.count();
let err_count = events
.iter()
.filter(|e| matches!(e, Event::Error(_)))
.count();
assert_eq!(err_count, 0, "TEXT salvage should suppress the parse error");
assert_eq!(send_count, 1, "device reset should re-init");
}
}