#![cfg(all(feature = "tnc", feature = "digipeat"))]
#[path = "../examples/esp32-riscv/src/beacon.rs"]
mod beacon;
#[path = "../examples/esp32-riscv/src/demod.rs"]
mod demod;
#[path = "../examples/esp32-riscv/src/digipeater.rs"]
mod digipeater;
use yodel::SampleRate;
use yodel::aprs::{AprsPacket, Latitude, Longitude, Position, Status, Symbol};
use yodel::ax25::{Address, PathHop, UiFrame};
use yodel::digipeat::WideLimit;
use yodel::tnc::{DefaultTncReceiver, TncConfig, TncReceiver, TncTransmitter};
const LAT_HUNDREDTHS: i64 = 294_350;
const LON_HUNDREDTHS: i64 = -432_175;
const LAT: i64 = LAT_HUNDREDTHS * yodel::geo::UNITS_PER_HUNDREDTH_MINUTE;
const LON: i64 = LON_HUNDREDTHS * yodel::geo::UNITS_PER_HUNDREDTH_MINUTE;
fn addr(call: &[u8], ssid: u8) -> Address {
match Address::new(call, ssid) {
Ok(a) => a,
Err(e) => panic!("{e}"),
}
}
#[test]
fn beacon_round_trips_through_main_crate_receiver() {
let mut pcm = vec![0i16; beacon::MAX_BEACON_SAMPLES];
let n = beacon::fill_position_beacon(
addr(b"N0CALL", 9),
LAT_HUNDREDTHS,
LON_HUNDREDTHS,
b"yodel esp32",
&mut pcm,
)
.expect("beacon must render");
assert!(n > 0, "beacon must produce samples");
assert_eq!(
n % beacon::SAMPLES_PER_BIT,
0,
"48 kHz / 1200 Bd is an integer samples-per-bit ratio"
);
assert!(n <= beacon::MAX_BEACON_SAMPLES);
let cfg = TncConfig::bell_202(SampleRate::new(beacon::SAMPLE_RATE_HZ).unwrap()).unwrap();
let mut rx: DefaultTncReceiver = TncReceiver::new(cfg).unwrap();
let mut decoded = 0;
for &s in &pcm[..n] {
if let Some(frame) = rx.push_i16(s) {
assert_eq!(frame.src().callsign.as_bytes(), b"N0CALL");
assert_eq!(frame.src().ssid.value(), 9);
assert_eq!(frame.dest().callsign.as_bytes(), b"APRS");
let expected = AprsPacket::Position(
Position::new(
Latitude::new(LAT).unwrap(),
Longitude::new(LON).unwrap(),
Symbol::CAR,
)
.with_comment(b"yodel esp32"),
);
assert_eq!(frame.aprs().expect("payload must parse"), expected);
decoded += 1;
}
}
assert_eq!(decoded, 1, "exactly one frame must decode");
}
#[test]
fn beacon_reports_buffer_too_small() {
let mut tiny = [0i16; 16];
assert_eq!(
beacon::fill_position_beacon(
addr(b"N0CALL", 9),
LAT_HUNDREDTHS,
LON_HUNDREDTHS,
b"",
&mut tiny
),
Err(beacon::BeaconError::BufferTooSmall)
);
}
fn synthesize_status(text: &'static [u8]) -> Vec<i16> {
let cfg = TncConfig::bell_202(SampleRate::new(demod::SAMPLE_RATE_HZ).unwrap()).unwrap();
let tx = TncTransmitter::new(cfg);
let mut info_buf = [0u8; 330];
let mut frame_buf = [0u8; 330];
tx.transmit_i16(
&AprsPacket::Status(Status { text }),
addr(b"APRS", 0),
addr(b"N0CALL", 7),
&[],
&mut info_buf,
&mut frame_buf,
)
.expect("transmit must succeed")
.collect()
}
#[test]
fn demod_module_decodes_synthesized_samples() {
let samples = synthesize_status(b"QRV from esp32 example");
let mut decoder = demod::AprsDecoder::new().expect("valid fixed config");
let mut seen = 0;
let frames = decoder.feed(&samples, |frame| {
assert_eq!(frame.src().callsign.as_bytes(), b"N0CALL");
assert_eq!(
demod::parse_aprs(frame).expect("payload must parse"),
AprsPacket::Status(Status {
text: b"QRV from esp32 example",
})
);
seen += 1;
});
assert_eq!(frames, 1);
assert_eq!(seen, 1);
assert_eq!(decoder.stats().frames_ok, 1);
}
#[test]
fn demod_module_survives_dma_like_odd_chunks() {
let samples = synthesize_status(b"chunked");
for chunk_len in [1usize, 7, 31, 173, 997] {
let mut decoder = demod::AprsDecoder::new().expect("valid fixed config");
let mut total = 0;
for chunk in samples.chunks(chunk_len) {
total += decoder.feed(chunk, |frame| {
assert_eq!(frame.info(), b">chunked");
});
}
assert_eq!(total, 1, "chunk size {chunk_len} must decode one frame");
assert_eq!(decoder.stats().frames_ok, 1);
}
}
fn synthesize_with_hops(src: Address, hops: &[PathHop], info: &[u8]) -> Vec<i16> {
let cfg = TncConfig::bell_202(SampleRate::new(digipeater::SAMPLE_RATE_HZ).unwrap()).unwrap();
let tx = TncTransmitter::new(cfg);
let frame = UiFrame::with_hops(addr(b"APRS", 0), src, hops, info).expect("legal path");
let mut frame_buf = [0u8; 330];
let len = frame.build(&mut frame_buf).expect("frame must build");
tx.frame_samples_i16(&frame_buf[..len]).collect()
}
fn feed_digi(digi: &mut digipeater::Digipeater, rx: &[i16], now_ms: u64) -> Vec<Vec<i16>> {
let mut tx_buf = vec![0i16; digipeater::MAX_RELAY_SAMPLES];
let mut out: Vec<Vec<i16>> = Vec::new();
for chunk in rx.chunks(512) {
digi.feed(chunk, now_ms, &mut tx_buf, |samples| {
out.push(samples.to_vec());
});
}
out
}
fn decode_one(samples: &[i16]) -> (Address, Address, Vec<PathHop>, Vec<u8>) {
let cfg = TncConfig::bell_202(SampleRate::new(digipeater::SAMPLE_RATE_HZ).unwrap()).unwrap();
let mut rx: DefaultTncReceiver = TncReceiver::new(cfg).unwrap();
let mut decoded = None;
for &s in samples {
if let Some(frame) = rx.push_i16(s) {
assert!(decoded.is_none(), "exactly one frame expected");
decoded = Some((
frame.src(),
frame.dest(),
frame.ui_frame().hops().collect(),
frame.info().to_vec(),
));
}
}
decoded.expect("relayed audio must decode")
}
fn make_digi() -> digipeater::Digipeater {
digipeater::Digipeater::new(addr(b"N0CALL", 1), WideLimit::TWO).expect("valid fixed config")
}
#[test]
fn digipeater_relays_wide2_1_with_h_bit_set() {
let heard = [PathHop::unused(addr(b"WIDE2", 1))];
let rx = synthesize_with_hops(addr(b"K1ABC", 9), &heard, b">digi me");
let mut digi = make_digi();
let relays = feed_digi(&mut digi, &rx, 1_000);
assert_eq!(relays.len(), 1, "exactly one relayed transmission");
let (src, dest, hops, info) = decode_one(&relays[0]);
assert_eq!(src, addr(b"K1ABC", 9), "source is preserved");
assert_eq!(dest, addr(b"APRS", 0), "destination is preserved");
assert_eq!(info, b">digi me", "payload is preserved");
assert_eq!(
hops,
vec![PathHop {
address: addr(b"WIDE2", 1),
repeated: true,
}],
"WIDE2-1 must be consumed in place: H bit set, no insertion"
);
assert_eq!(digi.stats().relayed, 1);
assert_eq!(digi.rx_stats().frames_ok, 1, "decoder saw one RX frame");
}
#[test]
fn digipeater_decrements_wide2_2_and_inserts_itself() {
let heard = [PathHop::unused(addr(b"WIDE2", 2))];
let rx = synthesize_with_hops(addr(b"K1ABC", 9), &heard, b">two hops");
let mut digi = make_digi();
let relays = feed_digi(&mut digi, &rx, 1_000);
assert_eq!(relays.len(), 1);
let (_, _, hops, _) = decode_one(&relays[0]);
assert_eq!(
hops,
vec![
PathHop {
address: addr(b"N0CALL", 1),
repeated: true,
},
PathHop::unused(addr(b"WIDE2", 1)),
],
"WIDE2-2 must become N0CALL-1*,WIDE2-1"
);
}
#[test]
fn digipeater_suppresses_dupes_until_window_expires() {
let heard = [PathHop::unused(addr(b"WIDE2", 1))];
let rx = synthesize_with_hops(addr(b"K1ABC", 9), &heard, b">once");
let mut digi = make_digi();
assert_eq!(feed_digi(&mut digi, &rx, 1_000).len(), 1, "first hearing");
assert_eq!(feed_digi(&mut digi, &rx, 6_000).len(), 0, "dupe");
assert_eq!(digi.stats().dupes, 1);
assert_eq!(feed_digi(&mut digi, &rx, 31_001).len(), 1, "window expired");
assert_eq!(digi.stats().relayed, 2);
}
#[test]
fn digipeater_never_relays_a_fully_used_path() {
let heard = [
PathHop {
address: addr(b"N0CALL", 1),
repeated: true,
},
PathHop {
address: addr(b"WIDE2", 1),
repeated: true,
},
];
let rx = synthesize_with_hops(addr(b"K1ABC", 9), &heard, b">spent");
let mut digi = make_digi();
assert_eq!(feed_digi(&mut digi, &rx, 1_000).len(), 0);
assert_eq!(digi.stats().heard, 1, "the frame WAS decoded");
assert_eq!(digi.stats().ignored, 1, "... and ignored, not relayed");
assert_eq!(digi.stats().relayed, 0);
}
#[test]
fn digipeater_ignores_paths_not_for_us() {
let heard = [PathHop::unused(addr(b"K9XYZ", 3))];
let rx = synthesize_with_hops(addr(b"K1ABC", 9), &heard, b">not you");
let mut digi = make_digi();
assert_eq!(feed_digi(&mut digi, &rx, 1_000).len(), 0);
assert_eq!(digi.stats().ignored, 1);
}