SEDSnet 4.0.0

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
# Rust Usage

This is the primary API and the source of truth for the Rust-facing behavior.

## Add as a dependency

```toml
sedsnet = { path = "path/to/sedsnet" }
```

Or from git:

```toml
sedsnet = { git = "https://github.com/Rylan-Meilutis/sedsnet.git", branch = "main" }
```

## Minimal router example

```rust
use sedsnet::config::{
    register_data_type_id_with_description, register_endpoint_id_with_description,
};
use sedsnet::router::{EndpointHandler, Router, RouterConfig};
use sedsnet::{
    DataEndpoint, DataType, MessageClass, MessageDataType, MessageElement, ReliableMode,
    TelemetryResult,
};

fn main() -> TelemetryResult<()> {
    let sd_card = register_endpoint_id_with_description(
        DataEndpoint(100),
        "SD_CARD",
        "Local storage endpoint",
        false,
    )?;
    let radio = register_endpoint_id_with_description(
        DataEndpoint(101),
        "RADIO",
        "External radio link",
        false,
    )?;
    register_data_type_id_with_description(
        DataType(100),
        "GPS_DATA",
        "Three f32 GPS values",
        MessageElement::Static(3, MessageDataType::Float32, MessageClass::Data),
        &[sd_card, radio],
        ReliableMode::None,
        80,
    )?;

    let handler = EndpointHandler::new_packet_handler(DataEndpoint::named("SD_CARD"), |pkt| {
        println!("rx: {pkt}");
        Ok(())
    });

    let router = Router::new(RouterConfig::new([handler]));

    router.add_side_packed("RADIO", |bytes| {
        let _ = bytes;
        Ok(())
    });

    router.log(DataType::named("GPS_DATA"), &[1.0_f32, 2.0, 3.0])?;
    router.process_all_queues()?;
    Ok(())
}
```

On `std` builds, `Router::new(...)` uses an internal monotonic clock. For tests, simulation, or
`no_std`, use `Router::new_with_clock(...)`.

## Runtime schema

User endpoints and data types are registered at runtime. There are no generated Rust variants for
application schema entries in v4.0.0.

Common options:

- call `register_endpoint(...)` / `register_data_type(...)` when the process starts
- call the `_id` variants when you need stable numeric IDs on the wire
- load a JSON seed with `register_schema_json_file(...)`, `register_schema_json_path(...)`, or
  `register_schema_json_bytes(...)`
- use `DataEndpoint::named("GROUNDSTATION")` and `DataType::named("GPS_DATA")` after registration
- use `try_named(...)` or `endpoint_definition_by_name(...)` / `data_type_definition_by_name(...)`
  when missing schema should be handled as a normal error path

Registering an endpoint handler for a missing endpoint auto-creates that endpoint in `std` builds
and broadcasts the new schema through discovery. Registering a data type or endpoint with the same
name/ID and a different shape returns an error.

## Network Variables and E2E Payloads

Network variables are latest-value caches for user data types. A router that enables a network
variable remembers the newest local or received packet for that type. User code uses a setter and
getter rather than registering a special endpoint: the setter commits the value to the network when
permissions allow, and the getter returns the cached value while internally requesting a refresh if
the value has never been seen or is stale. Caches are tiered: any router that has enabled or seen the
variable can answer the refresh from its local cache, so reconnecting boards can resync from a nearby
node instead of always reaching the original producer/master.

Data types can also advertise an E2E cryptography preference:

```rust
use sedsnet::config::register_data_type_id_with_description_and_e2e_encryption;
use sedsnet::router::{
    NetworkVariablePermissions, Router, RouterConfig, RouterE2eEncryptionMode,
};
use sedsnet::{
    DataEndpoint, DataType, E2eEncryptionPolicy, MessageClass, MessageDataType, MessageElement,
    ReliableMode,
};

let flight_state = register_data_type_id_with_description_and_e2e_encryption(
    DataType(3100),
    "FLIGHT_STATE",
    "network-managed flight state",
    MessageElement::Static(1, MessageDataType::UInt8, MessageClass::Data),
    &[DataEndpoint::named("RADIO")],
    ReliableMode::None,
    90,
    E2eEncryptionPolicy::RequireOn,
)?;

let router = Router::new(
    RouterConfig::default()
        .with_e2e_encryption(RouterE2eEncryptionMode::RequiredOnly)
        .with_e2e_key_id(7),
);
router.enable_network_variable(flight_state, NetworkVariablePermissions::READ_WRITE)?;
router.on_network_variable_update(flight_state, |pkt| {
    let state = pkt.data_as_u8()?;
    Ok(())
})?;
router.set_network_variable(pkt)?;
let cached = router.get_network_variable(flight_state, Some(1_000))?;
```

When a refresh request finds a peer with the value, the original value packet is replayed through
normal endpoint handlers. If the local router lacks read or write permission, the getter/setter
returns `TelemetryError::PermissionDenied` and peers answer refresh requests with a permission
error packet. `on_network_variable_update(...)` runs only for inbound updates and refresh replies
that change the local cache; local setters/seeds update the cache without firing that callback.

Router modes are:

- `Disabled`: never encrypt; reject data types marked `RequireOn`
- `RequiredOnly`: encrypt only required data types
- `Preferred`: encrypt required and preferred data types
- `ForceAll`: encrypt every non-control user data type

`RouterConfig::default()` and `RouterConfig::new(...)` use `Preferred` automatically with the
default `cryptography` feature; minimal builds that explicitly omit `cryptography` default to
`Disabled`.

The `cryptography` feature uses this provider order:

- registered C provider, for C firmware, OS crypto, secure elements, or hardware accelerators
- registered Rust provider, for Rust-only firmware or std applications wrapping OS crypto
- built-in software fallback, only after the application registers a key for the packet `key_id`

Packed side traffic carries visible routing metadata and an encrypted payload; the visible
header is authenticated as AAD so header tampering fails during open. The built-in fallback uses the
provisioned key for authenticated cryptography, but it does not create identity by itself.

```rust
#[cfg(feature = "cryptography")]
sedsnet::crypto::register_software_key(
    7,
    b"32-byte minimum deployment secret....",
)?;
```

For MITM resistance, boards must authenticate the key source. Common deployments use either
factory-provisioned PSKs or a network master that acts as the root authority. In the master-root
model, boards ship with the master public key or a join PSK, the master signs short-lived board
credentials, and peer/session keys are accepted only when the key exchange transcript validates back
to that root. Without that authenticated root, an active attacker can substitute keys before the
AEAD layer ever sees a packet.

The `cryptography` feature includes a compact 80-byte managed credential helper for this
master-root model. The master issues a `ManagedCredential` containing subject id, key id, epoch,
validity window, and permission bits; peers verify it against the provisioned root key before
accepting issued session/group keys.

For board-to-board deployments, run your board-owned quantum-resistant asynchronous key exchange
when discovery learns a peer, derive a low-cost symmetric traffic key, and pass that key through the
provider by `key_id`. Multi-drop endpoint traffic can use a shared group traffic key when every holder of
that endpoint must decode the same message; AEAD authentication still prevents a receiver from
modifying the frame for downstream boards without detection.

Fragmented links should encrypt the original message before splitting it. Fragments then carry a
message id, fragment index/count, source epoch, and route metadata; the receiver reassembles and
opens the original authenticated payload. On reconnect, routers should discard incomplete fragments
from older master epochs, refresh time/topology, and use network-variable getters to refresh any
state that is missing or stale. If the master epoch changed, resync from the current snapshot.

Discovery-enabled routers do not flood unknown user-data routes by fallback. They forward user data
only when discovery or explicit route policy identifies a path; discovery/control traffic still
propagates so the network can recover after partitions. For time-sliced radios, have the TX callback
return `TelemetryError::Io("side tx busy")` while the radio is in an RX window. Queued work will be
retried later, and measured bring-up/slot throughput can be fed into
`note_side_link_probe_sample(side, bytes, duration_ms)` to seed adaptive path selection and
control-plane throttling. Once a side is measured as slow, discovery sends minimal reachability
pings between infrequent full schema/topology/time-source refreshes, and router-managed time sync
throttles only that measured slow side while fast sides keep the configured normal cadence.

## Sides and routing

Routers and relays use named sides such as `UART`, `CAN`, or `RADIO`.

- `add_side_packed(...)` and `add_side_packet(...)` register egress handlers
- `remove_side(...)` tombstones a side without renumbering the remaining side ids
- `set_side_ingress_enabled(...)` and `set_side_egress_enabled(...)` control directional policy
- `set_route(...)` and `set_typed_route(...)` define runtime forwarding rules

There is no `RouterMode` anymore.

- `Router` now defaults to rule-driven full-mesh forwarding between eligible sides
- `Relay` keeps the same full-mesh default
- if you want sink-like behavior, disable the specific routes you do not want rather than choosing a
  separate constructor mode

Example:

```rust
use sedsnet::router::Router;

let router = Router::new(RouterConfig::default());
let side_a = router.add_side_packed("A", tx_a);
let side_b = router.add_side_packed("B", tx_b);
let side_c = router.add_side_packed("C", tx_c);

router.set_route(None, side_b, false)?;        // local TX does not go to B
router.set_route(Some(side_a), side_b, true)?; // allow A -> B
router.set_route(Some(side_b), side_a, false)?;// block B -> A
router.set_typed_route(None, DataType::named("GPS_DATA"), side_c, true)?;
router.set_side_egress_enabled(side_c, false)?; // ingress only
```

## Discovery and multi-path routing

With the `discovery` feature enabled, routers and relays learn which endpoints are reachable
through which sides.

- known paths are used directly
- unknown user-data paths are not flooded by fallback; discovery/control traffic still bootstraps
  route learning
- measured slow links receive minimal discovery pings most of the time, with full refreshes spaced
  out to preserve bandwidth
- link-local-only endpoints stay on sides marked `link_local_enabled`
- local plus source-side route rules still gate what discovery is allowed to use
- discovery also carries a transitive router graph, so exported topology keeps sender ownership and
  router-to-router connections instead of only flattening reachability per side

When discovery reports multiple candidate paths:

- normal traffic defaults to adaptive load balancing based on observed transmit bandwidth
- reliable traffic still fans out across all discovered candidates so one weak path does not hide a
  successful delivery on another path
- `set_source_route_mode(...)`, `set_route_weight(...)`, and `set_route_priority(...)` can still
  override the defaults

Packets already in flight also carry a compact internal wire contract: a frozen destination holder set
and enough payload-shape metadata to stay decodable while schema and topology updates are still propagating.
Applications do not build that contract manually; routers and relays attach and honor it automatically.

## Reliable delivery

Reliable delivery has two switches:

- the schema type itself must be marked reliable
- the router/relay side must opt in with `reliable_enabled: true`

That side option is per hop, not global. It controls what happens between the router/relay and
that side's TX callback.

```rust
use sedsnet::router::{Router, RouterConfig, RouterSideOptions};

let router = Router::new(RouterConfig::default());
router.add_side_packed_with_options(
    "RADIO",
    tx,
    RouterSideOptions {
        reliable_enabled: true,
        link_local_enabled: false,
        ..RouterSideOptions::default()
    },
);
```

If the underlying transport is already reliable, disable the router-level reliable layer with
`RouterConfig::with_reliable_enabled(false)`.

What `reliable_enabled` means on a side:

- `reliable_enabled: true` on a packed side wraps reliable schema traffic in the router/relay's
  hop-level reliable framing for that side only
- that hop-level framing adds sequence numbers, ACKs, packet requests, and retransmits
- `reliable_enabled: false` sends the application packet once on that side without the router's
  hop-level reliable wrapper
- packet-output sides (`add_side_packet*`) receive decoded `Packet` values, so they cannot carry
  the packed hop-level reliable wrapper even if `reliable_enabled` is set

For routers specifically:

- hop-level side reliability is separate from the source router's end-to-end reliable tracking
- a reliable packet can still be tracked end-to-end across the network even if one specific egress
  side is configured without hop-level reliability
- when discovery reports multiple candidate holders, reliable traffic still fans out across all of
  them unless you explicitly restrict routes

As of `3.11.0`, reliability has two layers:

- per-link reliable sequencing, ACKs, packet requests, and retransmits
- end-to-end verification from the source router to every currently discovered destination holder

The end-to-end path works like this:

- the source router tracks reliable packets it originated
- when a destination router delivers a reliable packet to a local handler, it emits an end-to-end
  acknowledgement tagged with its identity
- routers and relays learn the return path from the reliable packet’s ingress side and route that
  acknowledgement only where it needs to go
- the source keeps the packet in flight until all currently discovered holders have acknowledged
- if one end-to-end acknowledgement is lost, the source retransmits only toward the holders that
  are still outstanding until they respond or the retry limit is reached
- if discovery later expires one holder, the source removes that holder from the pending set and
  finishes once the remaining discovered holders are satisfied

That means reliable delivery is now verified at the application-destination boundary, not just per
hop, while still keeping reliable send non-blocking for newer packets on the same side/type lane.

For ordered reliable links, a receiver that gets packets after a gap buffers those later packets,
emits partial ACKs for them, and requests the missing sequence. Partial ACKs stop timeout-based
retransmits for packets the receiver already has, but explicit packet requests can still replay
them. When the missing sequence arrives, the buffered packets are dispatched immediately in order.

## Receiving packets

Common receive APIs:

- `rx_packed(bytes)`
- `rx_packed_queue(bytes)`
- `rx(packet)`
- `rx_queue(packet)`

Meaning of the variants:

- `rx_*` processes immediately in the current call
- `rx_*_queue` only enqueues work for a later `process_*` / `periodic` call
- `*_from_side(..., side_id)` tags the ingress with an explicit side id for route/discovery logic
- the non-`from_side` variants treat the input as locally-originated rather than arriving from a
  registered side

If an immediate router receive/transmit API is called from inside a side TX callback, the router
now defers that work onto its queue instead of recursively re-entering forwarding on the same
stack. Internal `SEDSNET_DISCOVERY` and `SEDSNET_TIME_SYNC` traffic stays router-owned;
applications should use the public discovery/time-sync APIs instead of constructing those packets
directly.

Use side-aware ingress only when you need to override the ingress side explicitly:

- `rx_packed_from_side(bytes, side_id)`
- `rx_from_side(packet, side_id)`

## Queue processing

The common maintenance calls are:

- `process_rx_queue()`
- `process_tx_queue()`
- `process_all_queues()`
- `periodic(timeout_ms)`
- `periodic_no_timesync(timeout_ms)` when `timesync` is enabled but you want to skip it for one
  loop

What each one does:

- `process_rx_queue()` drains queued receives only
- `process_tx_queue()` drains queued transmits only
- `process_all_queues()` drains both queues without a time budget
- `process_*_with_timeout(timeout_ms)` runs the same phase with a millisecond budget; `0` means
  drain fully
- `periodic(timeout_ms)` is the normal main-loop entry point because it also polls built-in
  discovery and, when enabled, time sync before draining queues

For relays, nested `process_tx_queue*` / `process_all_queues*` calls made from inside a side TX
callback are intentionally turned into no-ops so a side callback cannot recursively drive relay TX
on the same stack.

Router and relay queue-backed state shares the compile-time `MAX_QUEUE_BUDGET` dynamically.
That includes RX work, TX work, recent packet IDs, reliable buffers/replay state, and discovery
topology. Recent packet ID caches preallocate their final storage and reserve that byte cost
immediately. If the remaining budget is exhausted, older queued state is evicted; discovery
topology eviction emits a warning in `std` builds.

Use `router.export_memory_layout_json()` or `relay.export_memory_layout_json()` when profiling a
running node. The JSON reports shared allocated/used bytes plus per-area used/allocated bytes for
RX, ISR RX, TX, replay queues, reliable buffers, discovery, schema, and the network-variable cache.

Use `router.export_runtime_stats()` / `relay.export_runtime_stats()` or the matching C/Python
exports when profiling constrained links. Each side reports whether header-template compaction is
enabled, its effective side-transport profile, fixed-frame size, the compact-header target,
full/compact/chunk side-transport frame counts, emitted bytes, bytes saved versus canonical frames,
timestamp-delta and unchanged-timestamp compact frame counts, and the observed compact follow-up
overhead. Small-packet transport defaults to a 40-byte IPv6-like overhead target; call
`with_ipv4_like_compact_header_target()` on the side options when a stable tiny telemetry stream
should be held to a 20-byte IPv4-like target with unchanged compact timestamps omitted. Python
exposes the same profile selection with `add_side_packed_profile(..., profile="ipv4_like")`; C
callers use `seds_router_add_side_packed_profile(...)` or
`seds_relay_add_side_packed_profile(...)` with `SEDS_SIDE_TRANSPORT_PROFILE_IPV4_LIKE`.

For mixed links, keep absolute/delta timestamps for most traffic and omit unchanged timestamps only
for selected data types:

```rust
let opts = RouterSideOptions::default()
    .with_ipv6_like_compact_header_target()
    .with_omitted_unchanged_compact_timestamps_for_type(DataType::named("GPS_DATA"));
```

## P2P Service Ports

Discovery assigns and advertises compact node addresses and unique hostnames. Configure identity on
the router config:

```rust
let router = Router::new(
    RouterConfig::default()
        .with_hostname("http-service")
        .with_static_address(0x1020_3040),
);
```

Use `with_dynamic_address()`, `with_requested_address(address)`, or
`with_static_address(address)`. When segmented networks reunite, routers deconflict duplicate
addresses and hostnames deterministically. Static addresses are preserved first; if two static
nodes collide, the older identity keeps the address and newer identities move. Register
`on_address_change(...)` to be notified when the local address or hostname changes.

P2P service traffic is separate from endpoint broadcast telemetry. A service binds a port and
receives opaque bytes:

```rust
router.bind_p2p_port(80, |msg| {
    assert_eq!(msg.destination_port, 80);
    let http_request = msg.payload;
    Ok(())
})?;
```

Clients send by hostname so address changes do not break them, or by address when an application
needs explicit address targeting:

```rust
client.send_p2p_to_hostname(
    "http-service",
    80,
    49152,
    b"GET /status HTTP/1.1\r\nHost: http-service\r\n\r\n",
)?;

client.send_p2p_to_address(0x1020_3040, 80, 49152, b"GET / HTTP/1.1\r\n\r\n")?;
```

The service payload can carry protocols such as HTTP over SEDSnet links; SEDSnet supplies the
addressing, routing, reliability, and discovery layer instead of IP.

For protocols that want a connection lifecycle instead of standalone datagrams, bind a stream port
and open a stream:

```rust
router.bind_p2p_stream_port(8080, |event| {
    match event.kind {
        sedsnet::router::P2pStreamEventKind::Accepted => {
            // Store event.stream_id if this service wants to write a response.
        }
        sedsnet::router::P2pStreamEventKind::Data => {
            let bytes = event.payload;
        }
        sedsnet::router::P2pStreamEventKind::Closed
        | sedsnet::router::P2pStreamEventKind::Reset => {}
        sedsnet::router::P2pStreamEventKind::Connected => {}
    }
    Ok(())
})?;

let stream = client.open_p2p_stream_to_hostname("http-service", 8080, 49152)?;
client.send_p2p_stream(stream, b"GET /stream HTTP/1.1\r\n\r\n")?;
client.close_p2p_stream(stream)?;
```

Stream frames are carried inside `SEDSNET_P2P_MESSAGE`, so they use the same discovery routing,
compact addresses, target-sender contracts, and ordered reliable control path as P2P datagrams.

## Topology export

With discovery enabled, `export_topology()` returns the router's current learned view.

- `topology.routers` contains the top-level discovered router graph
- each router entry includes the sender ID, owned endpoints, owned time-sync source IDs, and
  connected router sender IDs
- `topology.links` is a deduplicated board-to-board edge list (`source`, `target`) for direct graph
  rendering
- exported JSON/Python dictionaries use `reachable_endpoints` and `advertised_endpoints` for
  schema-advertised names, with `reachable_endpoint_ids` and `advertised_endpoint_ids` available
  when code needs stable numeric validation
- SEDSnet-owned control endpoints (`SEDSNET_TIME_SYNC`, `SEDSNET_DISCOVERY`, `SEDSNET_ERROR`) are
  filtered out of user endpoint reachability fields
- each side route also includes `announcers`, so you can see which upstream router advertised the
  exported topology

Use `router.client_stats("BOARD_ID")` or `relay.client_stats("BOARD_ID")` to inspect one
discovered client. The snapshot includes connected/disconnected state, side IDs and side names,
last-seen/age timing, named reachable endpoints, reachable time-sync sources, and packet/byte
counters aggregated from the side(s) currently reaching that client.

Use `router.announce_leave()` or `relay.announce_leave()` before a planned shutdown or disconnect.
That queues a `SEDSNET_DISCOVERY_LEAVE` control packet so peers can prune topology immediately
instead of waiting for the discovery TTL. The C ABI also attempts this on router/relay free, but an
explicit leave is preferred when shutdown order matters.

## Reserved internal endpoints

Do not register user handlers for:

- `DataEndpoint::Discovery`
- `DataEndpoint::TimeSync` when the `timesync` feature is enabled

Those endpoints are owned by the router’s built-in control traffic.

## Time sync

When the `timesync` feature is enabled, the router maintains an internal network clock and handles
`SEDSNET_TIME_SYNC` traffic internally.

For ordinary loops, prefer `periodic(timeout_ms)` so time sync, discovery, and queue draining run
together.

See [Time-Sync](Time-Sync) for the protocol details.