tpt-link

Rust

Shared wire layer for the TPT ecosystem: CRC-checked frame format, UDP/serial/CAN transports, sequencing, heartbeat and reconnect with backoff. no_std-friendly core.

0 stars0 forks0 watchers5 open issuesApache License 2.0

Languages

Rust95.2%Go3.0%Lua1.6%Shell0.2%
README

tpt-link

Shared wire layer for the TPT ecosystem: the frame format, transports, sequencing, heartbeat and reconnect policy. See spec.txt for the specification.

Quickstart

[dependencies]
tpt-link = { git = "https://github.com/tpt-solutions/tpt-link", tag = "v0.2.0" }
# Inside the TPT repos: tpt-link = { path = "../tpt-link" }
use tpt_link::{Link, Loopback};

let (a, b) = Loopback::pair();           // swap for UdpTransport, SerialTransport, CanTransport
let (mut tx, mut rx) = (Link::new(a), Link::new(b));
tx.send(b"hello")?;
for payload in rx.poll()? {              // never blocks; damaged frames are counted and dropped
    println!("{}", String::from_utf8_lossy(&payload));
}
println!("{:?}", rx.stats());            // received, lost, crc_errors, ...

Minimum supported Rust version: 1.85, except for the proto feature, which needs 1.88 because micropb does. The git dependency needs the v0.2.0 tag to be pushed first.

Examples

Each runs with no hardware, except serial_bridge: cargo run --example <name>.

ExampleShows
loopback_helloThe smallest send and receive
udp_echoA client and an echo server over UDP
fault_injectionHow drops and corruption show up in LinkStats
heartbeat_monitorDeclaring a silent peer dead, with injected time
can_fragmentA 600-byte frame over classic CAN, on a virtual bus
sessionTyped messages, automatic heartbeats and liveness (envelope::Session)
authenticated_udpForged frames rejected over UDP (--features auth)
reliable_linkExactly-once delivery over a link losing 30 % of frames (reliable::Channel)
reconnectReconnector and Heartbeat keeping a UDP link up
schema_roundtripA protobuf Ack inside a frame (--features proto)
serial_bridgeForwards frames between a serial port and a UDP peer, unchanged (--features serial, needs hardware)

Command-line helper

tpt-link-cli works offline, which is useful for frames copied out of a log or a serial terminal:

cargo run --bin tpt-link-cli -- encode 3 "hi there"   # prints the frame as hex
cargo run --bin tpt-link-cli -- decode 0100000003...  # prints seq and payload, or the CRC error
cargo run --bin tpt-link-cli -- crc 123456789         # 0xcbf43926

Choosing a transport

TransportUse it forFeatureNotes
UdpTransportNetworks, host to hostdefaultDrops datagrams from any address but the peer
SerialTransportUART, USB-CDCserialUses the deframer with a 500 ms stall timeout
CanTransportCAN and CAN FD busesdefaultFragments frames; needs a CanBus
SocketCanBusLinux SocketCANcan-socketcanClassic CAN only for now
Loopback, FaultyTransportTestsdefaultIn memory

Features

FeatureAdds
std (default)Transports, Link, heartbeat, reconnect, fragmentation
allocFrame encoding without std
noneno_std frame decoder
serialSerialTransport
can-socketcanSocketCanBus (Linux)
protoProtobuf core messages in schema
authauth::Authenticated: encrypted, authenticated frames (see below)

Authentication

The CRC only catches damage. On UDP or CAN, anyone who can reach the port or the bus can forge frames. Wrap the transport in auth::Authenticated. Each session then runs an X25519 key exchange, authenticated with a pre-shared key, and every frame is sealed with ChaCha20-Poly1305 under the session key. Forged or altered frames are dropped and counted before they reach the link.

Replays are refused too. Each session has a random nonce that the receiver chooses, and a recording from an earlier session fails because its key is gone. Within a session, repeats and frames too far back to check are refused.

Session keys come from fresh ephemeral keys, so an earlier session stays secret even if the pre-shared key leaks later. The pre-shared key still authenticates the handshake, so keep it secret and rotate it: install the new key under a new id on both ends, activate it on both, then retire the old id. Key distribution is out of band, and there is no certificate or key exchange with a third party.

Restarts recover on their own. When the receiver restarts, the first frame it cannot verify triggers a resync, and the sender begins a new session. That frame and any sent before the resync arrives are lost, so use a reliable::Channel above it if loss matters.

Limits: frame sizes and timing are visible, and the pre-shared key is shared out of band. See the auth module docs before relying on it.

Troubleshooting

  • Serial port permission denied (Linux): add your user to the dialout group, then log in again.
  • No CAN hardware: use VirtualBus, or a virtual Linux interface: sudo modprobe vcan && sudo ip link add dev vcan0 type vcan && sudo ip link set up vcan0.
  • Windows CAN: not supported yet, because the vendor runtimes fail the licence policy (see TODO.md).
  • Frames lost before the first one arrives are not counted: the receiver has no expected sequence number until it sees a frame.
  • UDP frames never arrive: the peer address must match the address the other side sends from, including the IP, or the datagrams are dropped (see foreign()).

Features in brief

  • Frames: versioned, CRC-32 checked, sequenced (frame).
  • Byte streams: a deframer finds frames in serial or USB-CDC data, with a stall timeout for corrupted length fields (stream).
  • Transports: Loopback, UdpTransport, SerialTransport (feature serial), and FaultyTransport for tests.
  • Link: sequence numbers, loss counts, a Heartbeat for liveness, and a Reconnector with exponential backoff. See examples/reconnect.rs.
  • Supervisor: keeps a Session up over a reopenable transport. It reconnects through a connect function you supply, drops the link when the peer goes silent, and can give up after a set number of attempts (supervisor).
  • Messages: an optional envelope layer puts a type byte (data, heartbeat, ack, error) in the payload, and Session sends heartbeats and tracks liveness. The frame format is unchanged, so a plain Link still interoperates.
  • Fragmentation: splits messages larger than a small MTU, and reassembles them with expiry (fragment).
  • CAN: CanTransport carries link frames over any CanBus. SocketCanBus (feature can-socketcan, Linux) and VirtualBus for tests are included.
  • Protobuf core: Heartbeat, Ack and Error in proto/tpt_link.proto, generated into schema (feature proto). Regenerate with cargo run --manifest-path tools/gen-schema/Cargo.toml, which needs protoc.

Documentation and tools

  • docs/wire-format.md: the frame format byte by byte, with a worked example.
  • docs/auth-threat-model.md: what auth does and does not protect against.
  • tools/wireshark/tpt-link.lua: a Wireshark dissector for version 1 frames on UDP, with a CRC check. It is written from the wire-format guide and has not yet been loaded in Wireshark.
  • testdata/vectors.json: golden frame vectors, checked against an independent CRC implementation.

Development

cargo test
cargo clippy --all-targets -- -D warnings
cargo test --features serial
cargo run --example reconnect   # and the other examples
cargo test --features proto
cargo test --features can-socketcan --test socketcan   # Linux, needs vcan0
cargo deny check licenses bans sources advisories

CI (.github/workflows/ci.yml) runs these checks on Linux, Windows and macOS, and builds the core for the MCU targets thumbv7em-none-eabihf and riscv32imc-unknown-none-elf.

Licence

Dual-licensed under MIT or Apache-2.0, at your option. Copyright (c) 2026 TPT Solutions.