Skip to main content

enpose_api/
protocol.rs

1//! Wire protocol shared by the Enpose API and the on-device daemon.
2//!
3//! All packets are exactly [`PACKET_SIZE`] bytes laid out big-endian on
4//! the wire so a packet capture shows the literal magic bytes `EnpR`
5//! regardless of host byte order.
6//!
7//! Packet layout:
8//!
9//! ```text
10//! offset  size  field
11//! 0       4     MAGIC ("EnpR")
12//! 4       2     PROTOCOL_VERSION
13//! 6       4     serial number
14//! 10      1     has_extrinsics flag (0 or 1)
15//! 11      1     packet type (PKT_TYPE_*)
16//! ```
17
18/// UDP port the Enpose role-negotiation and discovery protocol uses.
19///
20/// Devices broadcast peer-announcement packets on this port at 1 Hz,
21/// and clients send discovery requests to this port. The primary
22/// device of every cluster replies to discovery requests by unicast
23/// from this port back to the requester's ephemeral port.
24pub const BROADCAST_PORT: u16 = 50884;
25
26/// Wire-protocol version this API was built against.
27///
28/// Bumped only on incompatible packet-format changes. Packets carrying
29/// a different version are still surfaced by [`crate::DeviceDiscovery`]
30/// (with `compatible = false`) so the caller can present a helpful
31/// "upgrade your firmware / client" entry instead of silently dropping
32/// the device.
33pub const PROTOCOL_VERSION: u16 = 1;
34
35/// Magic prefix of every packet — the ASCII bytes `EnpR` interpreted
36/// as a big-endian `u32`. Distinguishes Enpose traffic from any other
37/// UDP datagram that happens to land on [`BROADCAST_PORT`].
38pub const MAGIC: u32 = 0x456e7052;
39
40/// Fixed packet size across all packet types, so receivers can use a
41/// single `recv_from` buffer.
42pub const PACKET_SIZE: usize = 12;
43
44/// Packet type: a device announces its own identity (serial,
45/// extrinsics-calibration state). Sent both as the 1 Hz cluster
46/// broadcast and as the unicast reply to a discovery request.
47pub const PKT_TYPE_PEER_INFO: u8 = 0;
48
49/// Packet type: a client asks any reachable primary to identify
50/// itself. Only the cluster's elected primary replies, with a
51/// [`PKT_TYPE_PEER_INFO`] packet sent unicast to the requester.
52pub const PKT_TYPE_DISCOVERY_REQUEST: u8 = 1;
53
54/// UDP port the pose-streaming protocol uses. Clients send subscribe /
55/// keep-alive packets to this port on the device's primary, and the
56/// device unicasts [`PKT_TYPE_POSE_DATA`] packets back to each subscribed
57/// client. Separate from [`BROADCAST_PORT`] so discovery and streaming
58/// traffic never share a socket.
59pub const POSE_PORT: u16 = 50885;
60
61/// Packet type: a client subscribes to the pose stream. The same packet
62/// doubles as the keep-alive — a client resends it at 1 Hz, and the
63/// device drops a client it has not heard from within
64/// [`POSE_KEEPALIVE_TIMEOUT_SECS`]. Sent client → device on
65/// [`POSE_PORT`].
66pub const PKT_TYPE_POSE_SUBSCRIBE: u8 = 2;
67
68/// Packet type: a client unsubscribes from the pose stream. Lets the
69/// device drop the client immediately instead of waiting for the
70/// keep-alive timeout. Sent client → device on [`POSE_PORT`].
71pub const PKT_TYPE_POSE_UNSUBSCRIBE: u8 = 3;
72
73/// Packet type: a pose-data datagram. The fixed [`PACKET_SIZE`] header is
74/// followed by a MessagePack-encoded `Vec<MarkerPose>` starting at offset
75/// [`PACKET_SIZE`]. One datagram carries all markers localized from a
76/// single camera frame. Sent device → client on [`POSE_PORT`].
77pub const PKT_TYPE_POSE_DATA: u8 = 4;
78
79/// How long the device keeps a pose-stream client without hearing a
80/// subscribe/keep-alive packet from it before dropping the connection.
81pub const POSE_KEEPALIVE_TIMEOUT_SECS: u64 = 5;
82
83/// Interval at which a pose-stream client should resend its
84/// subscribe/keep-alive packet to stay connected.
85pub const POSE_KEEPALIVE_INTERVAL_SECS: u64 = 1;
86
87/// Decoded contents of a packet that passed the magic-bytes check.
88///
89/// The `version` field is intentionally not validated by
90/// [`parse_packet`]; callers decide whether to drop a version-mismatch
91/// packet (the daemon does this for peer announcements) or surface it
92/// to the user (discovery clients do this so an incompatible device
93/// can still be listed).
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub struct ParsedPacket {
96    pub version: u16,
97    pub serial: u32,
98    pub has_extrinsics: bool,
99    pub pkt_type: u8,
100}
101
102/// Build a peer-info packet using this API's current
103/// [`PROTOCOL_VERSION`].
104pub fn encode_peer_info(serial: u32, has_extrinsics: bool) -> [u8; PACKET_SIZE] {
105    encode(serial, has_extrinsics, PKT_TYPE_PEER_INFO)
106}
107
108/// Build a discovery-request packet. Carries `serial = 0` because the
109/// requester is anonymous — the replying device fills its own serial
110/// into the response.
111pub fn encode_discovery_request() -> [u8; PACKET_SIZE] {
112    encode(0, false, PKT_TYPE_DISCOVERY_REQUEST)
113}
114
115/// Build a pose-stream subscribe / keep-alive packet. Carries
116/// `serial = 0` because the client is anonymous; the device identifies
117/// the client by its source address.
118pub fn encode_pose_subscribe() -> [u8; PACKET_SIZE] {
119    encode(0, false, PKT_TYPE_POSE_SUBSCRIBE)
120}
121
122/// Build a pose-stream unsubscribe packet.
123pub fn encode_pose_unsubscribe() -> [u8; PACKET_SIZE] {
124    encode(0, false, PKT_TYPE_POSE_UNSUBSCRIBE)
125}
126
127/// Build the fixed header of a [`PKT_TYPE_POSE_DATA`] packet. The caller
128/// appends the MessagePack-encoded pose payload after this header; the
129/// receiver decodes the payload from offset [`PACKET_SIZE`]. `serial` is
130/// the sending device's factory serial.
131pub fn encode_pose_data_header(serial: u32) -> [u8; PACKET_SIZE] {
132    encode(serial, false, PKT_TYPE_POSE_DATA)
133}
134
135fn encode(serial: u32, has_extrinsics: bool, pkt_type: u8) -> [u8; PACKET_SIZE] {
136    let mut buf = [0u8; PACKET_SIZE];
137    buf[0..4].copy_from_slice(&MAGIC.to_be_bytes());
138    buf[4..6].copy_from_slice(&PROTOCOL_VERSION.to_be_bytes());
139    buf[6..10].copy_from_slice(&serial.to_be_bytes());
140    buf[10] = has_extrinsics as u8;
141    buf[11] = pkt_type;
142    buf
143}
144
145/// Decode a packet.
146///
147/// Returns `None` only when the buffer is shorter than [`PACKET_SIZE`]
148/// or when the magic prefix does not match — those are the conditions
149/// that mean "this is not an Enpose packet at all".
150///
151/// A packet with an unrecognised [`ParsedPacket::pkt_type`] or a
152/// [`ParsedPacket::version`] different from [`PROTOCOL_VERSION`] is
153/// returned to the caller unmodified; rejection policy is the
154/// caller's choice.
155pub fn parse_packet(data: &[u8]) -> Option<ParsedPacket> {
156    if data.len() < PACKET_SIZE {
157        return None;
158    }
159    let magic = u32::from_be_bytes(data[0..4].try_into().expect("length checked above"));
160    if magic != MAGIC {
161        return None;
162    }
163    let version = u16::from_be_bytes(data[4..6].try_into().expect("length checked above"));
164    let serial = u32::from_be_bytes(data[6..10].try_into().expect("length checked above"));
165    let has_extrinsics = data[10] != 0;
166    let pkt_type = data[11];
167    Some(ParsedPacket {
168        version,
169        serial,
170        has_extrinsics,
171        pkt_type,
172    })
173}
174
175#[cfg(test)]
176#[path = "protocol_tests.rs"]
177mod tests;