Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) an

## [Unreleased]

### Changed

- The `send_multicast` and `receive_reassemble` examples accept an optional local interface IPv4 address, pinning multicast egress and group membership to a chosen interface. This works around default-interface selection failing over Wi-Fi on Windows. Interface selection stays an application-level policy in the examples; the codec is unchanged.

## [0.2.0] - 2026-07-13

### Added
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,15 @@ When PONK data controls laser hardware, do not send decoded points directly to t
cargo run --example roundtrip
```

The two multicast examples accept an optional local interface IPv4 address:

```sh
cargo run --example receive_reassemble -- 192.168.1.20
cargo run --example send_multicast -- 192.168.1.20
```

Omit the address to let the operating system choose the interface. Pass an interface's own address to pin multicast to it — useful on Windows, where a Wi-Fi interface is often not selected by default and multicast otherwise never crosses the WLAN. Socket ownership stays with the application, so interface selection is an application-level policy shown in the examples rather than part of the codec.

## Minimum supported Rust version

The minimum supported Rust version (MSRV) is Rust **1.88**. CI checks both stable Rust and Rust 1.88. An MSRV increase requires a documented minor-version change while the crate is pre-1.0.
Expand Down
46 changes: 43 additions & 3 deletions examples/receive_reassemble.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
//! Join the PONK multicast group and reassemble incoming mixed-format frames.
//!
//! Run with: `cargo run --example receive_reassemble`
//! Run with: `cargo run --example receive_reassemble [interface-ipv4]`
//!
//! Pass a local interface's own IPv4 address to join the group on that
//! interface; omit it to let the operating system choose.
//!
//! Then, in another terminal, run the `send_multicast` example.

Expand All @@ -11,10 +14,22 @@ use ponk_protocol::{DEFAULT_PORT, MULTICAST_ADDR, PonkAssembler};
const RECV_BUFFER_SIZE: usize = 65_536;

fn main() -> std::io::Result<()> {
// Optionally join on a specific interface.
//
// With the default unspecified address the operating system picks the
// interface from its routing metrics. On Windows a Wi-Fi interface often
// loses that selection, so multicast is never received over WLAN. Passing
// the interface's own IPv4 address joins the group on that interface,
// mirroring the local-address field on TouchDesigner's UDP operators.
let interface = interface_from_args()?;

let group = Ipv4Addr::from(MULTICAST_ADDR);
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, DEFAULT_PORT))?;
socket.join_multicast_v4(&group, &Ipv4Addr::UNSPECIFIED)?;
println!("listening for PONK on {group}:{DEFAULT_PORT} — Ctrl-C to stop");
socket.join_multicast_v4(&group, &interface)?;
println!(
"listening for PONK on {group}:{DEFAULT_PORT} via interface {} — Ctrl-C to stop",
describe_interface(interface),
);

// Strict mode is the default. Configure sender-specific canonical boundary
// repair only when the application knows that a sender needs it.
Expand Down Expand Up @@ -45,3 +60,28 @@ fn main() -> std::io::Result<()> {
}
}
}

/// Read an optional interface IPv4 address from the first CLI argument.
///
/// Falls back to the unspecified address (all interfaces) when absent. A
/// present but unparseable argument is a hard error, so a typo cannot silently
/// revert to the default interface the example exists to override.
fn interface_from_args() -> std::io::Result<Ipv4Addr> {
match std::env::args().nth(1) {
None => Ok(Ipv4Addr::UNSPECIFIED),
Some(arg) => arg.parse().map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid interface address {arg:?}"),
)
}),
}
}

fn describe_interface(interface: Ipv4Addr) -> String {
if interface.is_unspecified() {
"default".to_string()
} else {
interface.to_string()
}
}
54 changes: 51 additions & 3 deletions examples/send_multicast.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
//! Encode a frame and multicast it as PONK datagrams.
//!
//! Run with: `cargo run --example send_multicast`
//! Run with: `cargo run --example send_multicast [interface-ipv4]`
//!
//! Pass a local interface's own IPv4 address to pin which interface the
//! multicast leaves through; omit it to let the operating system choose.
//!
//! Pair with the `receive_reassemble` example (run the receiver first).

use std::io::ErrorKind;
use std::net::{Ipv4Addr, SocketAddr, UdpSocket};

use ponk_protocol::{
Expand All @@ -14,7 +18,25 @@ use ponk_protocol::{
const MAX_DATAGRAM_LEN: usize = 1_472;

fn main() -> std::io::Result<()> {
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
// Optionally pin the outgoing interface by binding to its local address.
//
// With the default unspecified address the operating system picks the
// egress interface from its routing metrics. On Windows a Wi-Fi interface
// frequently loses that selection, so multicast never leaves the machine
// over WLAN. Binding to the interface's own IPv4 address reliably steers
// egress there on Windows, mirroring the local-address field on
// TouchDesigner's UDP operators.
//
// This is not a general guarantee: binding a local unicast source address
// is not the same as `IP_MULTICAST_IF`. On Linux (and other platforms) the
// routing table and `IP_MULTICAST_IF` govern multicast egress, so the bound
// address is only a hint there. Socket ownership stays with the application,
// so this policy lives in the example rather than the codec. An application
// needing the `IP_MULTICAST_IF` socket option directly can reach for
// `socket2` or the platform APIs.
let interface = interface_from_args()?;

let socket = UdpSocket::bind((interface, 0))?;
socket.set_multicast_ttl_v4(1)?;
socket.set_multicast_loop_v4(true)?;

Expand Down Expand Up @@ -51,10 +73,36 @@ fn main() -> std::io::Result<()> {
socket.send_to(datagram, target)?;
}
println!(
"sent {} datagram(s) to {} (multicast group {:?})",
"sent {} datagram(s) to {} via interface {} (multicast group {:?})",
datagrams.len(),
target,
describe_interface(interface),
MULTICAST_ADDR,
);
Ok(())
}

/// Read an optional interface IPv4 address from the first CLI argument.
///
/// Falls back to the unspecified address (all interfaces) when absent. A
/// present-but-unparsable argument is a hard error, since silently reverting
/// to the default would defeat the point of selecting an interface.
fn interface_from_args() -> std::io::Result<Ipv4Addr> {
match std::env::args().nth(1) {
None => Ok(Ipv4Addr::UNSPECIFIED),
Some(arg) => arg.parse().map_err(|_| {
std::io::Error::new(
ErrorKind::InvalidInput,
format!("invalid interface address {arg:?}"),
)
}),
}
}

fn describe_interface(interface: Ipv4Addr) -> String {
if interface.is_unspecified() {
"default".to_string()
} else {
interface.to_string()
}
}