SEDSnet 4.2.2

A memory safe, no_std-capable networking stack with routing, discovery, reliability, and Rust/C/Python bindings.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
//! Extra unit tests that cover previously-missing paths and invariants.
//!
//! These are white-box tests that exercise public APIs (and some
//! indirect behavior) to avoid changing visibility in core modules.
use crate::config::DataEndpoint;
use crate::tests::test_payload_len_for;
use crate::{
    TelemetryError, TelemetryErrorCode, TelemetryResult,
    config::DataType,
    packet::Packet,
    router::{Clock, EndpointHandler, Router, RouterConfig},
    wire_format,
};
use alloc::{string::String, sync::Arc};
use core::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;

/// A tiny helper clock; we rely on the blanket `impl<Fn() -> u64> Clock`.
fn zero_clock() -> Box<dyn Clock + Send + Sync> {
    Box::new(|| 0u64)
}

// --------------------------- Error/Code parity ---------------------------

/// Validate that `TelemetryError` ↔ `TelemetryErrorCode` mapping is
/// complete and stable, including the string forms.
#[test]
fn error_enum_code_roundtrip_and_strings() {
    let samples = [
        TelemetryError::InvalidType,
        TelemetryError::EmptyEndpoints,
        TelemetryError::Unpack("oops"),
        TelemetryError::Io("disk"),
        TelemetryError::HandlerError("fail"),
        TelemetryError::MissingPayload,
        TelemetryError::TimestampInvalid,
    ];
    for e in samples {
        let code = e.to_error_code();
        // must have a stable human string (starts with a '{' per current impl)
        assert!(code.as_str().starts_with('{'));
        // round-trip numeric space
        let back = TelemetryErrorCode::try_from_i32(code as i32);
        assert!(back.is_some(), "roundtrip failed for {code:?}");
    }
}

// --------------------------- Header-only parsing ---------------------------

/// Ensure header-only peek fails on truncated buffers (short read during
/// varint parsing).
#[test]
fn unpack_header_only_short_buffer_fails() {
    // v2 header is varint-based. Force a definite short read in the first varint.

    // Case A: only NEP present (0 endpoints), but no bytes for `ty` varint.
    let tiny = [0x00u8]; // NEP = 0
    let err = wire_format::peek_envelope(&tiny).unwrap_err();
    matches_deser_err(err);

    // Case B: NEP present, and a *truncated* varint (continuation bit set, but no following byte).
    let truncated = [0x00u8, 0x80]; // NEP=0, then start varint with continuation bit
    let err = wire_format::peek_envelope(&truncated).unwrap_err();
    matches_deser_err(err);
}

/// Ensure header size is a valid prefix of the packed wire image.
#[test]
fn header_size_is_prefix_of_wire_image() {
    let pkt = Packet::from_f32_slice(
        DataType::named("GPS_DATA"),
        &[1.0, 2.0, 3.0],
        &[DataEndpoint::named("SD_CARD"), DataEndpoint::named("RADIO")],
        123,
    )
    .unwrap();

    let wire = wire_format::pack_packet(&pkt);
    let hdr = wire_format::header_size_bytes(&pkt);
    assert!(hdr <= wire.len());

    // header must decode from the start (i.e., NEP + scalars exists)
    assert!(hdr > 0);
}

/// Helper: assert an error is a `Unpack` variant.
fn matches_deser_err(e: TelemetryError) {
    match e {
        TelemetryError::Unpack(_) => {}
        other => panic!("expected Unpack error, got {other:?}"),
    }
}

fn rewrite_crc32(buf: &mut [u8]) {
    if buf.len() < wire_format::CRC32_BYTES {
        return;
    }
    let data_len = buf.len() - wire_format::CRC32_BYTES;
    let mut hasher = crc32fast::Hasher::new();
    hasher.update(&buf[..data_len]);
    let crc = hasher.finalize();
    buf[data_len..].copy_from_slice(&crc.to_le_bytes());
}

/// Ensure packing is canonical: pack -> unpack -> pack
/// produces identical bytes (ULEB128 canonical form).
#[test]
fn packer_is_canonical_roundtrip() {
    use crate::config::{DataEndpoint, DataType};
    use crate::{packet::Packet, wire_format};

    // Dynamic payload to avoid schema constraints and let us vary sizes later.
    let msg = "hello world";
    let pkt = Packet::from_str_slice(
        DataType::TelemetryError,
        msg,
        &[DataEndpoint::named("SD_CARD"), DataEndpoint::named("RADIO")],
        0,
    )
    .unwrap();

    let wire1 = wire_format::pack_packet(&pkt);
    let pkt2 = wire_format::unpack_packet(&wire1).unwrap();
    let wire2 = wire_format::pack_packet(&pkt2);

    // ULEB128 is canonical (no leading 0x80 “more” bytes), so bytes must match
    assert_eq!(&*wire1, &*wire2, "packer must be canonical");
}

#[test]
fn pack_unpack_roundtrip_matches_packet_identity() {
    let pkt = Packet::from_str_slice(
        DataType::TelemetryError,
        "pack/unpack roundtrip check",
        &[DataEndpoint::TelemetryError],
        456,
    )
    .unwrap();

    let packed = wire_format::pack_packet(&pkt);
    assert_eq!(packed, wire_format::pack_packet(&pkt));

    let unpacked = wire_format::unpack_packet(&packed).unwrap();
    assert_eq!(unpacked.packet_id(), pkt.packet_id());
    assert_eq!(unpacked.payload(), pkt.payload());
}

/// Validate varint scalar growth: header and wire size should increase
/// when fields that are encoded as varints get larger.
#[test]
fn packer_varint_scalars_grow_as_expected() {
    use crate::config::{DataEndpoint, DataType};
    use crate::{packet::Packet, wire_format};

    fn non_rle_ascii(len: usize) -> Vec<u8> {
        let mut out = Vec::with_capacity(len);
        for i in 0..len {
            // Alternating lowercase letters with no >=3-byte runs and valid UTF-8 bytes.
            out.push(b'a' + ((i % 26) as u8));
        }
        out
    }

    // Helper to build a TelemetryError payload with a sender of the given length.
    fn pkt_with(len: usize, sender_len: usize, ts: u64) -> Packet {
        let sender_bytes = non_rle_ascii(sender_len);
        let s: String = sender_bytes.iter().map(|b| char::from(*b)).collect();
        let payload = non_rle_ascii(len); // dynamic payload (String type)
        Packet::new(
            DataType::TelemetryError,
            &[DataEndpoint::named("SD_CARD")],
            &s,
            ts,
            Arc::<[u8]>::from(payload),
        )
        .unwrap()
    }

    // Case 1: small (all varints fit in 1 byte)
    let p1 = pkt_with(10, 5, 0x7F); // <= 127
    let w1 = wire_format::pack_packet(&p1);
    let h1 = wire_format::header_size_bytes(&p1);
    assert!(h1 > 4, "NEP + 4 one-byte varints minimum");

    // Case 2: larger payload grows data_size; sender text itself is discovery metadata,
    // not part of the packet header.
    let p2 = pkt_with(200, 200, 0x7F);
    let w2 = wire_format::pack_packet(&p2);
    let h2 = wire_format::header_size_bytes(&p2);
    assert!(w2.len() > w1.len(), "wire should grow with larger varints");
    assert!(h2 >= h1, "header should not shrink with larger varints");

    // Case 3: bigger timestamp to push it beyond 1 byte (and usually >2)
    let p3 = pkt_with(200, 200, 1u64 << 40); // forces 6-byte varint
    let w3 = wire_format::pack_packet(&p3);
    let h3 = wire_format::header_size_bytes(&p3);
    assert!(
        w3.len() > w2.len(),
        "wire should grow with larger timestamp"
    );
    assert!(h3 > h2, "header should grow with larger timestamp");

    // Size function must match exact output
    assert_eq!(wire_format::packet_wire_size(&p3), w3.len());
}

/// Stress test for endpoint bitpacking across many endpoints and repeated
/// copies, ensuring endpoints and payload round-trip.
#[test]
fn endpoints_bitpack_roundtrip_many_and_extremes() {
    use crate::{
        MAX_VALUE_DATA_ENDPOINT,
        config::{DataEndpoint, DataType},
        packet::Packet,
        wire_format,
    };

    // Build a long endpoint list by cycling through all enum values (0..=MAX)
    let mut eps = Vec::<DataEndpoint>::new();
    for i in 0..=MAX_VALUE_DATA_ENDPOINT {
        if let Some(ep) = DataEndpoint::try_from_u32(i) {
            eps.push(ep);
        }
    }
    // Repeat to make the bitstream cross multiple bytes
    let mut endpoints = Vec::new();
    for _ in 0..4 {
        endpoints.extend_from_slice(&eps);
    }

    // Make payload dynamic so schema doesn't get in the way
    let payload = vec![0x55u8; 257]; // force 2-byte varint for data_size
    let pkt = Packet::new(
        DataType::TelemetryError,
        &endpoints,
        "sender",
        123456,
        Arc::<[u8]>::from(payload),
    )
    .unwrap();

    let wire = wire_format::pack_packet(&pkt);
    let back = wire_format::unpack_packet(&wire).unwrap();
    let has_all_endpoints = back.endpoints().iter().all(|ep| endpoints.contains(ep));
    assert!(has_all_endpoints, "endpoints must roundtrip 1:1");
    assert_eq!(back.data_type(), pkt.data_type());
    assert_eq!(back.timestamp(), pkt.timestamp());
    assert_eq!(back.payload(), pkt.payload());
    assert_eq!(wire_format::packet_wire_size(&pkt), wire.len());
}

/// For large sender/payload/timestamp, ensure `peek_envelope` and full
/// unpacking agree on header fields and payload.
#[test]
fn peek_envelope_matches_full_parse_on_large_values() {
    use crate::config::{DataEndpoint, DataType};
    use crate::{packet::Packet, wire_format};

    let sender = "S".repeat(10_000); // big sender (varint grows)
    let payload = vec![b'h'; 4096];
    let ts = (1u64 << 40) + 123; // large ts (varint grows)

    let pkt = Packet::new(
        DataType::TelemetryError, // String-typed
        &[DataEndpoint::named("SD_CARD"), DataEndpoint::named("RADIO")],
        &sender,
        ts,
        Arc::<[u8]>::from(payload),
    )
    .unwrap();

    let wire = wire_format::pack_packet(&pkt);
    assert!(
        !wire
            .windows(sender.len())
            .any(|window| window == sender.as_bytes()),
        "sender hostname must be discovery/config metadata, not packet-header bytes"
    );
    let env = wire_format::peek_envelope(&wire).unwrap();
    let full = wire_format::unpack_packet(&wire).unwrap();

    assert_eq!(env.ty, pkt.data_type());
    assert_eq!(env.sender.as_ref(), pkt.sender());
    assert_eq!(env.timestamp_ms, pkt.timestamp());
    assert_eq!(&*env.endpoints, pkt.endpoints());

    assert_eq!(full.data_type(), pkt.data_type());
    assert_eq!(full.timestamp(), pkt.timestamp());
    assert_eq!(full.endpoints(), pkt.endpoints());
    assert_eq!(full.payload(), pkt.payload());
}

/// Corrupt endpoint bits in the bitmap to encode an out-of-range value,
/// and ensure unpacking fails with an appropriate error.
#[test]
fn corrupt_endpoint_bits_yields_bad_endpoint_error() {
    use crate::{
        MAX_VALUE_DATA_ENDPOINT,
        config::{DataEndpoint, DataType},
        packet::Packet,
        wire_format,
    };

    // Recompute EP_BITS the same way the module does.
    let bits = 32 - MAX_VALUE_DATA_ENDPOINT.leading_zeros();
    let ep_bits: u8 = if bits == 0 { 1 } else { bits as u8 };
    // If EP_BITS is exactly the minimum bits to encode MAX, there is room for values > MAX.
    let upper_val = (1u64 << ep_bits) - 1;
    if upper_val as u32 <= MAX_VALUE_DATA_ENDPOINT {
        // Nothing to corrupt beyond max—skip test (no larger representable value).
        return;
    }

    // Build a simple, valid packet with at least 1 endpoint.
    let pkt = Packet::from_f32_slice(
        DataType::named("GPS_DATA"),
        &[1.0, 2.0, 3.0],
        &[DataEndpoint::named("SD_CARD")],
        123,
    )
    .unwrap();
    let mut wire = wire_format::pack_packet(&pkt).to_vec();

    // Compute where endpoint bits start (right after header varints)
    let ep_offset = wire_format::header_size_bytes(&pkt);
    assert!(ep_offset < wire.len());

    // Overwrite the *first* endpoint with an out-of-range value in the bitstream.
    // Bits are packed LSB-first.
    let mut v = upper_val;
    for (bitpos, _) in (0..ep_bits).enumerate() {
        let byte_idx = ep_offset + (bitpos / 8);
        let bit_off = bitpos % 8;
        // Set bit if the corresponding bit of v is 1
        if (v & 1) != 0 {
            wire[byte_idx] |= 1 << bit_off;
        } else {
            wire[byte_idx] &= !(1 << bit_off);
        }
        v >>= 1;
    }
    rewrite_crc32(&mut wire);

    // Now unpacking must fail with a Unpack("bad endpoint") error.
    let err = wire_format::unpack_packet(&wire).unwrap_err();
    match err {
        TelemetryError::Unpack(msg) if msg.contains("endpoint") => {}
        other => panic!("expected bad endpoint unpack error, got {other:?}"),
    }
}

/// Sanity check that header size is between 0 and full packet size, and
/// that the computed wire size matches packed length.
#[test]
fn header_size_is_prefix_and_less_than_total() {
    use crate::config::{DataEndpoint, DataType};
    use crate::{packet::Packet, wire_format};

    let pkt = Packet::from_f32_slice(
        DataType::named("GPS_DATA"),
        &[1.0, 2.0, 3.0],
        &[DataEndpoint::named("SD_CARD"), DataEndpoint::named("RADIO")],
        999,
    )
    .unwrap();

    let wire = wire_format::pack_packet(&pkt);
    let hdr = wire_format::header_size_bytes(&pkt);

    assert!(hdr > 0 && hdr < wire.len());
    assert_eq!(wire_format::packet_wire_size(&pkt), wire.len());
}

// --------------------------- UTF-8 trimming behavior ---------------------------

/// Ensure `data_as_utf8_ref` trims trailing NUL bytes and returns a `&str`
/// with just the meaningful content.
#[test]
fn data_as_utf8_ref_trims_trailing_nuls() {
    // Use a String-typed message kind. TelemetryError is used by the router with
    // a string payload and typically mapped to MessageDataType::String.
    let ty = DataType::TelemetryError;
    let mut buf = vec![0u8; test_payload_len_for(ty)];

    let s = b"hello\0\0";
    buf[..s.len()].copy_from_slice(s);

    let pkt = Packet::new(
        ty,
        &[DataEndpoint::named("SD_CARD")],
        "tester",
        0,
        Arc::<[u8]>::from(buf),
    )
    .unwrap();

    assert_eq!(pkt.data_as_utf8_ref(), Some("hello"));
}

// --------------------------- Queue clear semantics ---------------------------

/// After calling `clear_queues`, no pending TX/RX items should be processed.
#[test]
fn clear_queues_prevents_further_processing() {
    // Transmit "bus" that counts frames sent.
    let tx_count = Arc::new(AtomicUsize::new(0));
    let tx_count_c = tx_count.clone();
    let tx = move |bytes: &[u8]| -> TelemetryResult<()> {
        assert!(!bytes.is_empty());
        tx_count_c.fetch_add(1, Ordering::SeqCst);
        Ok(())
    };

    // Local handler that counts receives.
    let rx_count = Arc::new(AtomicUsize::new(0));
    let rx_count_c = rx_count.clone();
    let handler = EndpointHandler::new_packet_handler(
        DataEndpoint::named("SD_CARD"),
        move |_pkt: &Packet| {
            rx_count_c.fetch_add(1, Ordering::SeqCst);
            Ok(())
        },
    );

    let r = Router::new_with_clock(RouterConfig::new(vec![handler]), zero_clock());
    r.add_side_packed("tx", tx);

    // Enqueue one TX and one RX
    let pkt_tx = Packet::from_f32_slice(
        DataType::named("GPS_DATA"),
        &[1.0_f32, 2.0, 3.0],
        &[DataEndpoint::named("SD_CARD"), DataEndpoint::named("RADIO")],
        0,
    )
    .unwrap();
    let pkt_rx = Packet::from_f32_slice(
        DataType::named("GPS_DATA"),
        &[4.0_f32, 5.0, 6.0],
        &[DataEndpoint::named("SD_CARD")], // only local to avoid extra TX during receive
        0,
    )
    .unwrap();

    r.tx_queue(pkt_tx).unwrap();
    r.rx_queue(pkt_rx).unwrap();

    // Clearing should drop both queues before any processing.
    r.clear_queues();

    r.drain_queues_with_timeout(0).unwrap();
    assert_eq!(
        tx_count.load(Ordering::SeqCst),
        0,
        "should not TX after clear"
    );
    assert_eq!(
        rx_count.load(Ordering::SeqCst),
        0,
        "should not RX after clear"
    );
}

// --------------------------- Retry semantics (indirect) ---------------------------

/// Verify local handler retry count matches `MAX_NUMBER_OF_RETRYS` (assumed 3),
/// and that the final error is a `HandlerError`.
#[test]
fn local_handler_retry_attempts_are_three() {
    // This test assumes MAX_NUMBER_OF_RETRYS == 3 in router. If that constant changes,
    // update the expected count below.
    const EXPECTED_ATTEMPTS: usize = 3; // initial try + retries

    let counter = Arc::new(AtomicUsize::new(0));
    let counter_c = counter.clone();

    // A handler that always fails but bumps a counter on each attempt.
    let failing = EndpointHandler::new_packet_handler(
        DataEndpoint::named("SD_CARD"),
        move |_pkt: &Packet| {
            counter_c.fetch_add(1, Ordering::SeqCst);
            Err(TelemetryError::BadArg)
        },
    );

    // Router with no TX (we only care about local handler invocation count).
    let r = Router::new_with_clock(RouterConfig::new(vec![failing]), zero_clock());

    // Build a valid packet addressed to the failing endpoint.
    let pkt = Packet::from_f32_slice(
        DataType::named("GPS_DATA"),
        &[1.0_f32, 2.0, 3.0],
        &[DataEndpoint::named("SD_CARD")],
        0,
    )
    .unwrap();

    // Sending should surface a HandlerError after all retries.
    let res = r.tx(pkt);
    match res {
        Err(TelemetryError::HandlerError(_)) => {}
        other => panic!("expected HandlerError after retries, got {other:?}"),
    }

    assert_eq!(
        counter.load(Ordering::SeqCst),
        EXPECTED_ATTEMPTS,
        "handler should be invoked exactly {EXPECTED_ATTEMPTS} times"
    );
}

// --------------------------- from_u8_slice sanity ---------------------------

/// Ensure `Packet::from_u8_slice` builds a valid GPS packet with
/// expected length and timestamp.
#[test]
fn from_f32_slice_builds_valid_packet() {
    let need = test_payload_len_for(DataType::named("GPS_DATA")) / 4; // f32 count
    assert_eq!(need, 3); // schema sanity

    let bytes = vec![5.3f32; need];
    let pkt = Packet::from_f32_slice(
        DataType::named("GPS_DATA"),
        &bytes,
        &[DataEndpoint::named("SD_CARD")],
        12345,
    )
    .unwrap();

    assert_eq!(pkt.payload().len(), 12);
    assert_eq!(pkt.data_size(), 12);
    assert_eq!(pkt.timestamp(), 12345);
}

#[test]
fn from_none_slice_builds_valid_packet() {
    let need = 0; // f32 count
    assert_eq!(need, 0); // schema sanity

    let pkt = Packet::from_no_data(
        DataType::named("HEARTBEAT"),
        &[DataEndpoint::named("SD_CARD")],
        12345,
    )
    .unwrap();

    assert_eq!(pkt.payload().len(), 0);
    assert_eq!(pkt.data_size(), 0);
    assert_eq!(pkt.timestamp(), 12345);
}

#[test]
fn heartbeat_reaches_each_independently_discovered_network_side() {
    crate::tests::ensure_common_test_schema();
    use crate::config::register_data_type_with_description;
    use crate::discovery::{TopologyBoardNode, build_discovery_topology};
    use crate::{MessageClass, MessageDataType, MessageElement, ReliableMode};

    // Packed sides enable the destination contract used by the real
    // GroundStation links. The contract names owners on both sides; route
    // selection must not collapse that multi-segment delivery to one
    // adaptive path.
    let seen_a: Arc<Mutex<Vec<Arc<[u8]>>>> = Arc::new(Mutex::new(Vec::new()));
    let seen_b: Arc<Mutex<Vec<Arc<[u8]>>>> = Arc::new(Mutex::new(Vec::new()));
    let seen_a_cb = seen_a.clone();
    let seen_b_cb = seen_b.clone();
    let router = Router::new_with_clock(RouterConfig::default(), zero_clock());
    let side_a = router.add_side_packed("A", move |bytes| {
        seen_a_cb.lock().unwrap().push(Arc::from(bytes));
        Ok(())
    });
    let side_b = router.add_side_packed("B", move |bytes| {
        seen_b_cb.lock().unwrap().push(Arc::from(bytes));
        Ok(())
    });
    let endpoint = DataEndpoint::named("SD_CARD");
    let heartbeat = DataType::try_named("HEARTBEAT").unwrap_or_else(|| {
        register_data_type_with_description(
            "HEARTBEAT",
            "test network heartbeat",
            MessageElement::Static(0, MessageDataType::NoData, MessageClass::Data),
            &[endpoint],
            ReliableMode::None,
            255,
        )
        .expect("register HEARTBEAT")
    });

    router
        .rx_from_side(
            &build_discovery_topology(
                "REMOTE_A",
                1,
                &[TopologyBoardNode {
                    sender_id: "REMOTE_A".into(),
                    reachable_endpoints: vec![endpoint],
                    reachable_timesync_sources: vec![],
                    connections: vec![],
                }],
            )
            .unwrap(),
            side_a,
        )
        .unwrap();
    router
        .rx_from_side(
            &build_discovery_topology(
                "BRIDGE_B",
                1,
                &[
                    TopologyBoardNode {
                        sender_id: "BRIDGE_B".into(),
                        reachable_endpoints: vec![],
                        reachable_timesync_sources: vec![],
                        connections: vec!["REMOTE_B".into()],
                    },
                    TopologyBoardNode {
                        sender_id: "REMOTE_B".into(),
                        reachable_endpoints: vec![endpoint],
                        reachable_timesync_sources: vec![],
                        connections: vec!["BRIDGE_B".into()],
                    },
                ],
            )
            .unwrap(),
            side_b,
        )
        .unwrap();
    router.process_all_queues().unwrap();
    seen_a.lock().unwrap().clear();
    seen_b.lock().unwrap().clear();

    router
        .tx(Packet::from_no_data(heartbeat, &[endpoint], 2).unwrap())
        .unwrap();

    assert_eq!(seen_a.lock().unwrap().len(), 1);
    assert_eq!(seen_b.lock().unwrap().len(), 1);
    assert_eq!(
        router.debug_end_to_end_tracked_count(),
        0,
        "periodic freshness packets must not create an end-to-end ACK storm",
    );
}

// --------------------------- Header-only happy path smoke ---------------------------

/// Header-only peek (`peek_envelope`) should match full parse for a normal
/// encoded GPS packet.
#[test]
fn unpack_header_only_then_full_parse_matches() {
    // Build a normal packet then compare header-only vs full.
    let endpoints = &[DataEndpoint::named("SD_CARD"), DataEndpoint::named("RADIO")];
    let pkt = Packet::from_f32_slice(
        DataType::named("GPS_DATA"),
        &[5.25_f32, 3.5, 1.0],
        endpoints,
        42,
    )
    .unwrap();
    let wire = wire_format::pack_packet(&pkt);

    let env = wire_format::peek_envelope(&wire).unwrap();
    assert_eq!(env.ty, pkt.data_type());
    assert_eq!(&*env.endpoints, pkt.endpoints());
    assert_eq!(env.sender.as_ref(), pkt.sender());
    assert_eq!(env.timestamp_ms, pkt.timestamp());

    let round = wire_format::unpack_packet(&wire).unwrap();
    round.validate().unwrap();
    assert_eq!(round.data_type(), pkt.data_type());
    assert_eq!(round.data_size(), pkt.data_size());
    assert_eq!(round.timestamp(), pkt.timestamp());
    assert_eq!(round.endpoints(), pkt.endpoints());
    assert_eq!(round.payload(), pkt.payload());
}

// --------------------------- TX failure -> error to locals (smoke) ---------------------------

/// Smoke test: TX failure should emit a `TelemetryError` packet to local
/// endpoints (exact string validated by more specific tests).
#[test]
fn tx_failure_emits_error_to_local_endpoints() {
    // A transmitter that always fails.
    let failing_tx = |_bytes: &[u8]| -> TelemetryResult<()> { Err(TelemetryError::Io("boom")) };

    // Capture what the local endpoint sees (should include a TelemetryError).
    let last_payload = Arc::new(Mutex::new(String::new()));
    let last_payload_c = last_payload.clone();

    let capturing =
        EndpointHandler::new_packet_handler(DataEndpoint::named("SD_CARD"), move |pkt: &Packet| {
            if pkt.data_type() == DataType::TelemetryError {
                *last_payload_c.lock().unwrap() = pkt.as_string();
            }
            Ok(())
        });

    let r = Router::new_with_clock(RouterConfig::new(vec![capturing]), zero_clock());
    r.add_side_packed("tx", failing_tx);

    // Include both a local and a non-local endpoint to force remote TX.
    let pkt = Packet::from_f32_slice(
        DataType::named("GPS_DATA"),
        &[1.0_f32, 2.0, 3.0],
        &[DataEndpoint::named("SD_CARD"), DataEndpoint::named("RADIO")],
        7,
    )
    .unwrap();

    let res = r.tx(pkt);
    match res {
        Err(TelemetryError::HandlerError(_)) => {} // TX path wraps as HandlerError
        other => panic!("expected HandlerError from TX failure, got {other:?}"),
    }

    // Ensure something was captured (exact string is covered elsewhere)
    let got = last_payload.lock().unwrap().clone();
    assert!(
        !got.is_empty(),
        "expected TelemetryError to be delivered locally after TX failure"
    );
}