use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::engine::RawFrame;
use crate::engine::format::days_to_ymd;
use crate::engine::format::ikonvert::{
self, IkonvertLine, TX_LIMIT_OFF, TX_OFFLINE, TX_ONLINE_ALL, TX_ONLINE_NORMAL,
synthesize_network_status,
};
use super::{DeviceDecoder, DeviceEncoder, DeviceEvent, DeviceHandle};
pub const IKONVERT_SYNTHETIC_PGN: u32 = 0x40000;
#[derive(Debug, Clone, Default)]
pub struct Config {
pub rx_list: Option<String>,
pub tx_list: Option<String>,
pub rate_limit_off: bool,
pub skip_init: bool,
}
pub fn run(
reader: Box<dyn Read + Send>,
writer: Box<dyn Write + Send>,
config: Config,
) -> DeviceHandle {
let skip_init = config.skip_init;
super::run(Decoder::new(config), Encoder { skip_init }, reader, writer)
}
pub struct Decoder {
acc: String,
config: Config,
init_state: Arc<Mutex<u32>>,
}
impl Decoder {
pub fn new(config: Config) -> Self {
let start = if config.skip_init {
STATE_DONE
} else {
STATE_WAIT_OFFLINE_ACK
};
Self {
acc: String::with_capacity(1024),
config,
init_state: Arc::new(Mutex::new(start)),
}
}
}
impl DeviceDecoder for Decoder {
fn decode(&mut self, bytes: &[u8], events: &mut Vec<DeviceEvent>) {
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(now_iso_ms());
events.push(DeviceEvent::Frame(f));
}
Ok(IkonvertLine::Control(c)) => self.handle_control(&c, 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, events);
} else {
events.push(DeviceEvent::Error(format!("ikonvert: {e}")));
}
}
}
}
}
}
impl Decoder {
fn handle_control(&self, body: &str, events: &mut Vec<DeviceEvent>) {
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, now_iso_ms()) {
Some(frame) => {
log::debug!(
"ikonvert: synthesized network status (load={} count={})",
frame.data[0],
frame.data[5],
);
events.push(DeviceEvent::Frame(frame));
}
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(&self, events: &mut Vec<DeviceEvent>) {
let mut state = self.init_state.lock().expect("ikonvert state poisoned");
match *state {
STATE_WAIT_OFFLINE_ACK => {
*state = STATE_SEND_RESET;
if let Some(cmd) = next_init_command(&mut state, &self.config) {
events.push(DeviceEvent::SendBytes(cmd.into_bytes()));
} else {
log::info!("ikonvert: initialization complete");
}
}
STATE_DONE => {
log::warn!("ikonvert: device reset detected, re-initialising");
*state = STATE_SEND_RESET;
if let Some(cmd) = next_init_command(&mut state, &self.config) {
events.push(DeviceEvent::SendBytes(cmd.into_bytes()));
}
}
_ => {
log::debug!("ikonvert: TEXT during init at state {}", *state);
}
}
}
fn advance_after_ack(&self, events: &mut Vec<DeviceEvent>) {
let mut state = self.init_state.lock().expect("ikonvert state poisoned");
if *state == STATE_DONE || *state == STATE_WAIT_OFFLINE_ACK || (*state).is_multiple_of(2) {
return;
}
*state -= 1;
if let Some(cmd) = next_init_command(&mut state, &self.config) {
events.push(DeviceEvent::SendBytes(cmd.into_bytes()));
} else {
log::info!("ikonvert: initialization complete");
}
}
fn maybe_resend_offline(&self, events: &mut Vec<DeviceEvent>) {
let state = self.init_state.lock().expect("ikonvert state poisoned");
if *state != STATE_WAIT_OFFLINE_ACK {
return;
}
drop(state);
log::debug!("ikonvert: resending N2NET_OFFLINE");
events.push(DeviceEvent::SendBytes(TX_OFFLINE.as_bytes().to_vec()));
}
}
pub struct Encoder {
skip_init: bool,
}
impl DeviceEncoder for Encoder {
fn init_bytes(&self) -> Vec<u8> {
if self.skip_init {
return Vec::new();
}
log::info!("ikonvert: initialization start");
TX_OFFLINE.as_bytes().to_vec()
}
fn encode_frame(&self, frame: &RawFrame) -> Option<Vec<u8>> {
if frame.pgn >= IKONVERT_SYNTHETIC_PGN {
log::debug!("ikonvert: skipping synthetic PGN {}", frame.pgn);
return None;
}
Some(ikonvert::encode_tx_frame(frame).into_bytes())
}
}
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, config: &Config) -> 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) = &config.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) = &config.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 config.rx_list.is_some() {
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 config.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,
}
}
}
fn now_iso_ms() -> String {
let dur = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = dur.as_secs() as i64;
let ms = dur.subsec_millis();
let days = secs.div_euclid(86_400);
let day_secs = secs.rem_euclid(86_400) as u32;
let h = day_secs / 3600;
let m = (day_secs / 60) % 60;
let s = day_secs % 60;
let (y, mo, d) = days_to_ymd(days);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}.{ms:03}")
}
#[cfg(test)]
mod tests {
use super::*;
fn run_init(config: Config) -> Vec<String> {
let encoder = Encoder { skip_init: false };
let first = String::from_utf8(encoder.init_bytes()).unwrap();
let mut commands = vec![first];
let decoder = Decoder::new(config);
let mut first_response = true;
loop {
let mut events = Vec::new();
if first_response {
decoder.handle_control("TEXT,iKonvert_simulated", &mut events);
first_response = false;
} else {
decoder.handle_control("ACK,test", &mut events);
}
let mut emitted_one = false;
for ev in events {
if let DeviceEvent::SendBytes(b) = ev {
commands.push(String::from_utf8(b).unwrap());
emitted_one = true;
}
}
if !emitted_one {
break;
}
}
commands
}
#[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 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 decoder = Decoder::new(Config::default());
let mut events = Vec::new();
decoder.handle_control("TEXT,welcome", &mut events); events.clear();
decoder.handle_control("ACK,reset", &mut events); events.clear();
decoder.handle_control("ACK,init", &mut events); events.clear();
decoder.handle_control("ACK,stray", &mut events); assert!(events.is_empty(), "post-init ACKs must not emit commands");
}
#[test]
fn heartbeat_at_offline_wait_resends_offline() {
let decoder = Decoder::new(Config::default());
let mut events = Vec::new();
decoder.handle_control("000000,,,,,,", &mut events);
assert_eq!(events.len(), 1, "heartbeat should trigger one resend");
match &events[0] {
DeviceEvent::SendBytes(b) => {
assert_eq!(String::from_utf8_lossy(b), "$PDGY,N2NET_OFFLINE\r\n");
}
_ => panic!("expected SendBytes"),
}
}
#[test]
fn populated_heartbeat_emits_synthetic_frame() {
let decoder = Decoder::new(Config::default());
let mut events = Vec::new();
decoder.handle_control("TEXT,welcome", &mut events); events.clear();
decoder.handle_control("ACK,reset", &mut events); events.clear();
decoder.handle_control("ACK,init", &mut events); events.clear();
decoder.handle_control("000000,38,1,38,753,2,0", &mut events);
assert_eq!(events.len(), 1, "data heartbeat should emit one frame");
match &events[0] {
DeviceEvent::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 decoder = Decoder::new(Config::default());
let mut events = Vec::new();
decoder.handle_control("TEXT,banner", &mut events); events.clear();
decoder.handle_control("ACK,reset", &mut events); events.clear();
decoder.handle_control("ACK,init", &mut events); events.clear();
decoder.handle_control("TEXT,banner2", &mut events);
assert_eq!(events.len(), 1, "post-init TEXT should re-init");
match &events[0] {
DeviceEvent::SendBytes(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 = Decoder::new(Config::default());
let mut events = Vec::new();
decoder.handle_control("TEXT,init", &mut events); events.clear();
decoder.handle_control("ACK,reset", &mut events); events.clear();
decoder.handle_control("ACK,init", &mut events); events.clear();
let bad = b"!PDGY,65350,2,27,\xff$PDGY,TEXT,Digital_Yacht_iKonvert_v2_49_x\r\n";
decoder.decode(bad, &mut events);
let send_count = events
.iter()
.filter(|e| matches!(e, DeviceEvent::SendBytes(_)))
.count();
let err_count = events
.iter()
.filter(|e| matches!(e, DeviceEvent::Error(_)))
.count();
assert_eq!(err_count, 0, "TEXT salvage should suppress the parse error");
assert_eq!(send_count, 1, "device reset should re-init");
}
}