use std::fmt;
use crate::engine::decode::{DecodedField, DecodedPgn, FieldValue};
use super::{effective_precision, format_date, format_time, time_precision_for};
#[derive(Debug, Default, Clone)]
pub struct TextOptions {
#[allow(dead_code)]
pub show_unavailable: bool,
pub debug: bool,
pub geo: GeoFormat,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum GeoFormat {
#[default]
Dd,
Dm,
Dms,
}
pub fn write_text<W: fmt::Write>(w: &mut W, pgn: &DecodedPgn, opts: &TextOptions) -> fmt::Result {
if let Some(ts) = &pgn.timestamp {
w.write_str(&crate::engine::format::normalize_timestamp(ts))?;
w.write_char(' ')?;
}
write!(
w,
"{prio} {src:>3} {dst:>3} {pgn:>6} {desc}:",
prio = pgn.prio,
src = pgn.src,
dst = pgn.dst,
pgn = pgn.pgn,
desc = pgn.description,
)?;
let mut sep = " ";
for f in &pgn.fields {
if matches!(&f.value, FieldValue::Reserved { bytes, .. } if bytes.is_empty()) {
continue;
}
if let FieldValue::Spare { value, .. } = &f.value
&& *value == 0
{
continue;
}
if let Some(bo) = f.bit_offset {
let payload_bits = (pgn.data.len() as u32).saturating_mul(8);
if bo >= payload_bits {
continue;
}
}
w.write_str(sep)?;
w.write_char(' ')?;
if let (Some(iter), true) = (f.repeat_index, f.repeat_set != 0) {
write!(w, "{name} {iter} = ", name = f.name(), iter = iter + 1)?;
} else {
write!(w, "{name} = ", name = f.name())?;
}
write_field_value(w, f, opts.geo, opts.debug, &pgn.data)?;
if opts.debug {
write_text_debug_suffix(w, f, &pgn.data)?;
}
sep = ";";
}
Ok(())
}
fn write_text_debug_suffix<W: fmt::Write>(
w: &mut W,
f: &DecodedField,
payload: &[u8],
) -> fmt::Result {
let (Some(bo), Some(bl)) = (f.bit_offset, f.bit_length) else {
return Ok(());
};
if bl == 0 {
return Ok(());
}
if bo % 8 == 0 && bl % 8 == 0 {
let start = (bo / 8) as usize;
let end = ((bo + bl) / 8) as usize;
let end_clamped = end.min(payload.len());
if start >= end_clamped {
return Ok(());
}
w.write_str(" (bytes = \"")?;
for (i, b) in payload[start..end_clamped].iter().enumerate() {
if i > 0 {
w.write_char(' ')?;
}
write!(w, "{:02X}", b)?;
}
w.write_str("\")")?;
return Ok(());
}
use crate::engine::bits::extract_bits;
let signed = matches!(
f.value,
FieldValue::Number(_) if f.unit().is_some()
);
let Some(ex) = extract_bits(payload, bo as usize, bl as usize, signed, 0) else {
return Ok(());
};
let raw = ex.value as u64;
let shift = bo % 8;
let byte_span = (shift + bl).div_ceil(8) as usize;
let shifted: u128 = (raw as u128) << shift;
w.write_str(" (bytes = \"")?;
for i in 0..byte_span {
if i > 0 {
w.write_char(' ')?;
}
let byte = ((shifted >> (i * 8)) & 0xff) as u8;
write!(w, "{:02X}", byte)?;
}
w.write_char('"')?;
if bl % 8 != 0 {
w.write_str(", bits = \"")?;
for i in (0..bl).rev() {
let byte = (raw >> (i / 8)) & 0xff;
let bit = (byte >> (i % 8)) & 1;
w.write_char(if bit == 1 { '1' } else { '0' })?;
}
w.write_char('"')?;
}
w.write_char(')')
}
fn is_latlon(f: &DecodedField) -> bool {
if f.unit() != Some("deg") {
return false;
}
let n: &str = f.name();
n.contains("atitude") || n.contains("ongitude") || n.contains("atit") || n.contains("ongit")
}
fn write_latlon<W: fmt::Write>(w: &mut W, dd: f64, name: &str, geo: GeoFormat) -> fmt::Result {
if matches!(geo, GeoFormat::Dd) {
return write!(w, "{:>10.7}", dd);
}
let is_lon = name.contains("ongit");
let dir = if is_lon {
if dd >= 0.0 { 'E' } else { 'W' }
} else if dd >= 0.0 {
'N'
} else {
'S'
};
let abs = dd.abs();
let deg = abs.floor();
let rem = abs - deg;
match geo {
GeoFormat::Dm => {
let minutes = rem * 60.0;
write!(w, "{:02}d {:6.3} {dir}", deg as u32, minutes)
}
GeoFormat::Dms => {
let mut minutes = (rem * 60.0).floor();
let mut seconds = rem * 3600.0 - 60.0 * minutes;
if seconds >= 59.9995 {
minutes += 1.0;
seconds = 0.0;
}
let mut deg = deg;
if minutes >= 60.0 {
deg += 1.0;
minutes = 0.0;
}
write!(
w,
"{:02}d {:02}' {:06.3}\"{dir}",
deg as u32, minutes as u32, seconds
)
}
GeoFormat::Dd => unreachable!(),
}
}
fn write_unit<W: fmt::Write>(w: &mut W, f: &DecodedField) -> fmt::Result {
if let Some(unit) = &f.unit() {
write!(w, " {}", unit)?;
}
Ok(())
}
fn write_field_value<W: fmt::Write>(
w: &mut W,
f: &DecodedField,
geo: GeoFormat,
debug: bool,
payload: &[u8],
) -> fmt::Result {
match &f.value {
FieldValue::Decimal(d) => w.write_str(d),
FieldValue::Number(v) => {
if is_latlon(f) {
return write_latlon(w, *v, f.name(), geo);
}
let p = effective_precision(f.precision(), f.resolution());
write!(w, "{:.*}", p, v)?;
write_unit(w, f)?;
Ok(())
}
FieldValue::Integer(v) => {
write!(w, "{}", v)?;
write_unit(w, f)?;
Ok(())
}
FieldValue::Float(v) => {
super::write_c_g(w, *v)?;
if let Some(unit) = &f.unit() {
write!(w, " {}", unit)?;
}
Ok(())
}
FieldValue::Binary(bytes) => {
for (i, b) in bytes.iter().enumerate() {
if i > 0 {
w.write_char(' ')?;
}
write!(w, "{:02X}", b)?;
}
Ok(())
}
FieldValue::Lookup { value, name } => {
if let Some(n) = name {
w.write_str(n)
} else {
write!(w, "{}", value)
}
}
FieldValue::BitField { bits, .. } => {
if bits.is_empty() {
w.write_str("None")
} else {
for (i, (bv, n)) in bits.iter().enumerate() {
if i > 0 {
w.write_char(',')?;
}
match n {
Some(n) => w.write_str(n)?,
None => write!(w, "{}", bv)?,
}
}
Ok(())
}
}
FieldValue::String(s) => w.write_str(s),
FieldValue::Date(d) => format_date(*d, w),
FieldValue::Time { seconds, .. } => {
let p = time_precision_for(f.resolution());
format_time(*seconds, p, true, w)
}
FieldValue::Mmsi(v) => write!(w, "\"{:09}\"", v),
FieldValue::Pgn { value, description } => {
write!(w, "{}", value)?;
if let Some(desc) = description {
write!(w, " ({})", desc)?;
}
Ok(())
}
FieldValue::Reserved { bytes, .. } | FieldValue::Spare { bytes, .. } => {
for (i, b) in bytes.iter().enumerate() {
if i > 0 {
w.write_char(' ')?;
}
write!(w, "{:02X}", b)?;
}
Ok(())
}
FieldValue::IsoName { value, subfields } => {
write!(w, "0x{:x}", value)?;
if !subfields.is_empty() {
let name_payload: &[u8] = match f.bit_offset {
Some(bo) if bo % 8 == 0 && (bo / 8) as usize + 8 <= payload.len() => {
let s = (bo / 8) as usize;
&payload[s..s + 8]
}
_ => &[],
};
w.write_str(" name = [")?;
let mut sep = "";
for sf in subfields {
if matches!(
sf.value,
FieldValue::NotAvailable
| FieldValue::Reserved { .. }
| FieldValue::Spare { .. }
) {
continue;
}
w.write_str(sep)?;
write!(w, " {name} = ", name = sf.name())?;
write_field_value(w, sf, geo, debug, name_payload)?;
if debug {
write_text_debug_suffix(w, sf, name_payload)?;
}
sep = ";";
}
w.write_str("]")?;
}
Ok(())
}
FieldValue::NotAvailable => w.write_str("Unknown"),
FieldValue::OutOfRange { .. } => w.write_str("Out Of Range"),
FieldValue::ReservedValue { .. } => w.write_str("Reserved"),
FieldValue::Unsupported { field_type } => write!(w, "<unsupported:{}>", field_type),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::decode::{DecodedField, DecodedPgn, FieldValue};
fn sample_pgn() -> DecodedPgn {
DecodedPgn {
timestamp: Some("2022-09-10T12:10:16.614Z".into()),
prio: 6,
pgn: 60928,
src: 5,
dst: 255,
description: "ISO Address Claim",
id: "isoAddressClaim",
id_is_pinned: false,
data: Vec::new(),
fields: {
let info = crate::engine::PgnDatabase::embedded(crate::engine::Units::Metric)
.first_pgn(60928)
.expect("PGN 60928 present");
vec![
DecodedField {
info: &info.fields[0],
value: FieldValue::Integer(1_088_507),
bit_offset: None,
bit_length: None,
repeat_index: None,
repeat_set: 0,
overrides: None,
},
DecodedField {
info: &info.fields[1],
value: FieldValue::Lookup {
value: 275,
name: Some("Navico"),
},
bit_offset: None,
bit_length: None,
repeat_index: None,
repeat_set: 0,
overrides: None,
},
]
},
has_repeating_set: [false, false],
index_by_order: [i8::MIN; 32],
}
}
#[test]
fn header_format_matches_canboat() {
let pgn = sample_pgn();
let mut out = String::new();
write_text(&mut out, &pgn, &TextOptions::default()).unwrap();
assert!(out.starts_with(
"2022-09-10T12:10:16.614Z 6 5 255 60928 ISO Address Claim: Unique Number = 1088507"
));
}
#[test]
fn separates_fields_with_semicolon_space() {
let pgn = sample_pgn();
let mut out = String::new();
write_text(&mut out, &pgn, &TextOptions::default()).unwrap();
assert!(out.contains("; Manufacturer Code = Navico"));
}
#[test]
fn end_to_end_iso_address_claim() {
use crate::engine::{PgnDatabase, RawFrame};
let db = PgnDatabase::embedded(crate::engine::Units::Metric);
let frame = RawFrame {
timestamp: Some("2022-09-10T12:10:16.614Z".into()),
prio: 6,
pgn: 60928,
src: 5,
dst: 255,
data: smallvec::smallvec![0xfb, 0x9b, 0x70, 0x22, 0x00, 0x9b, 0x50, 0xc0],
};
let pgn = db.decode(&frame).unwrap();
let mut out = String::new();
write_text(&mut out, &pgn, &TextOptions::default()).unwrap();
assert!(
out.starts_with(
"2022-09-10T12:10:16.614Z 6 5 255 60928 ISO Address Claim: \
Unique Number = 1088507; Manufacturer Code = Navico;"
),
"got: {}",
out
);
}
#[test]
fn unavailable_renders_as_unknown() {
let mut pgn = sample_pgn();
pgn.fields[1].value = FieldValue::NotAvailable;
let mut out = String::new();
write_text(&mut out, &pgn, &TextOptions::default()).unwrap();
assert!(out.contains("Manufacturer Code = Unknown"));
assert!(out.contains("Unique Number = 1088507"));
}
}