enpose_api/ffi.rs
1//! C ABI for the Enpose API.
2//!
3//! This is a thin wrapper over the idiomatic Rust API ([`DeviceDiscovery`],
4//! [`PoseStream`], [`MarkerPose`]); Rust applications should use those types
5//! directly. The functions here exist so C (and other languages with a C
6//! FFI) can drive the same discover-then-stream workflow. The matching C
7//! declarations live in the hand-written `c/enpose_api.h`.
8//!
9//! Conventions:
10//!
11//! * Stateful objects are exposed as opaque pointers created by a `connect`
12//! function and released by a `free` function.
13//! * Variable-length results (the device list, a pose batch) are returned as
14//! library-allocated arrays; the caller must release each with the matching
15//! `*_array_free` function and must not free the memory itself.
16//! * Functions returning [`EnposeStatus`] report success as
17//! [`EnposeStatus::Ok`]; functions returning a pointer report failure as
18//! `NULL`.
19//! * Every entry point is panic-safe: a panic is caught at the boundary and
20//! turned into an error result rather than unwinding into C.
21
22use std::ffi::{CStr, c_char};
23use std::net::IpAddr;
24use std::panic::{AssertUnwindSafe, catch_unwind};
25use std::ptr;
26use std::str::FromStr;
27
28use crate::devicediscovery::{DeviceDiscovery, DeviceInfo};
29use crate::marker_pose::MarkerPose;
30use crate::posestream::PoseStream;
31
32/// Result code returned by the fallible C API functions.
33#[repr(C)]
34#[derive(Clone, Copy, PartialEq, Eq, Debug)]
35pub enum EnposeStatus {
36 /// The call succeeded.
37 Ok = 0,
38 /// A required argument was null or otherwise invalid.
39 InvalidArg = -1,
40 /// An I/O error occurred (e.g. the network operation failed).
41 Io = -2,
42 /// The Rust side panicked; the call was aborted cleanly.
43 Panic = -3,
44}
45
46/// Size of the IP string buffer in [`EnposeDeviceInfo`], including the
47/// terminating null. The API is IPv4-only; the buffer keeps the 46-byte
48/// `INET6_ADDRSTRLEN` size for ABI headroom.
49const IP_BUF_LEN: usize = 46;
50
51/// C-compatible view of one discovered device.
52///
53/// Mirrors [`DeviceInfo`], but the address is rendered into a fixed,
54/// null-terminated string buffer instead of a Rust `IpAddr`.
55#[repr(C)]
56pub struct EnposeDeviceInfo {
57 /// Null-terminated IPv4 address string.
58 pub ip: [c_char; IP_BUF_LEN],
59 /// Factory serial number of the device.
60 pub serial: u32,
61 /// Non-zero when the device's protocol version matches this library.
62 pub compatible: bool,
63}
64
65/// Discover Enpose devices on the local network.
66///
67/// On [`EnposeStatus::Ok`], `*out_devices` points to a library-allocated
68/// array of `*out_count` [`EnposeDeviceInfo`] entries (or `NULL` with count
69/// `0` when none were found). Release it with
70/// [`enpose_device_info_array_free`].
71///
72/// # Safety
73///
74/// `out_devices` and `out_count` must be valid, writable pointers.
75#[unsafe(no_mangle)]
76pub unsafe extern "C" fn enpose_discover(
77 out_devices: *mut *mut EnposeDeviceInfo,
78 out_count: *mut usize,
79) -> EnposeStatus {
80 let result = catch_unwind(|| {
81 if out_devices.is_null() || out_count.is_null() {
82 return EnposeStatus::InvalidArg;
83 }
84 let devices = match DeviceDiscovery::new().discover() {
85 Ok(d) => d,
86 Err(_) => return EnposeStatus::Io,
87 };
88 let (ptr, count) = leak_array(devices.iter().map(device_to_c).collect());
89 unsafe {
90 *out_devices = ptr;
91 *out_count = count;
92 }
93 EnposeStatus::Ok
94 });
95 result.unwrap_or(EnposeStatus::Panic)
96}
97
98/// Release an array returned by [`enpose_discover`].
99///
100/// # Safety
101///
102/// `devices`/`count` must be a pair returned by [`enpose_discover`] and not
103/// already freed. Passing `NULL` is allowed and does nothing.
104#[unsafe(no_mangle)]
105pub unsafe extern "C" fn enpose_device_info_array_free(
106 devices: *mut EnposeDeviceInfo,
107 count: usize,
108) {
109 let _ = catch_unwind(|| unsafe { free_array(devices, count) });
110}
111
112/// Connect a pose stream to the device at `ip` (an IPv4 string).
113///
114/// The Enpose API is IPv4-only; a non-IPv4 address fails. When `create_thread`
115/// is non-zero, a background thread receives and buffers poses (the preferred
116/// mode); otherwise poses are collected when [`enpose_pose_stream_receive`] is
117/// called. Returns an opaque handle, or `NULL` on failure (a non-IPv4 or
118/// invalid address, or a connection error). Release the handle with
119/// [`enpose_pose_stream_free`].
120///
121/// # Safety
122///
123/// `ip` must be a valid, null-terminated C string.
124#[unsafe(no_mangle)]
125pub unsafe extern "C" fn enpose_pose_stream_connect(
126 ip: *const c_char,
127 create_thread: bool,
128) -> *mut PoseStream {
129 let result = catch_unwind(|| {
130 if ip.is_null() {
131 return ptr::null_mut();
132 }
133 let Ok(text) = (unsafe { CStr::from_ptr(ip) }).to_str() else {
134 return ptr::null_mut();
135 };
136 let Ok(addr) = IpAddr::from_str(text) else {
137 return ptr::null_mut();
138 };
139 match PoseStream::from_ip(addr, create_thread) {
140 Ok(stream) => Box::into_raw(Box::new(stream)),
141 Err(_) => ptr::null_mut(),
142 }
143 });
144 result.unwrap_or(ptr::null_mut())
145}
146
147/// Return the marker poses received from the stream.
148///
149/// When `block` is non-zero, waits for at least one pose update before
150/// returning, up to a 3-second timeout (after which it returns empty);
151/// otherwise returns immediately with whatever has arrived since the previous
152/// call (possibly none). On [`EnposeStatus::Ok`], `*out_poses` points to a
153/// library-allocated array of `*out_count` [`MarkerPose`] entries (or `NULL`
154/// with count `0` when none have arrived). Release it with
155/// [`enpose_marker_pose_array_free`].
156///
157/// # Safety
158///
159/// `stream` must be a handle from [`enpose_pose_stream_connect`] that has
160/// not been freed; `out_poses` and `out_count` must be valid, writable
161/// pointers.
162#[unsafe(no_mangle)]
163pub unsafe extern "C" fn enpose_pose_stream_receive(
164 stream: *mut PoseStream,
165 block: bool,
166 out_poses: *mut *mut MarkerPose,
167 out_count: *mut usize,
168) -> EnposeStatus {
169 let result = catch_unwind(AssertUnwindSafe(|| {
170 if stream.is_null() || out_poses.is_null() || out_count.is_null() {
171 return EnposeStatus::InvalidArg;
172 }
173 let stream = unsafe { &mut *stream };
174 match stream.receive_pose_updates(block) {
175 Ok(poses) => {
176 let (ptr, count) = leak_array(poses);
177 unsafe {
178 *out_poses = ptr;
179 *out_count = count;
180 }
181 EnposeStatus::Ok
182 }
183 Err(_) => EnposeStatus::Io,
184 }
185 }));
186 result.unwrap_or(EnposeStatus::Panic)
187}
188
189/// Release an array returned by [`enpose_pose_stream_receive`].
190///
191/// # Safety
192///
193/// `poses`/`count` must be a pair returned by [`enpose_pose_stream_receive`]
194/// and not already freed. Passing `NULL` is allowed and does nothing.
195#[unsafe(no_mangle)]
196pub unsafe extern "C" fn enpose_marker_pose_array_free(poses: *mut MarkerPose, count: usize) {
197 let _ = catch_unwind(|| unsafe { free_array(poses, count) });
198}
199
200/// Disconnect and free a pose stream handle.
201///
202/// Disconnects from the device and releases all resources. Passing `NULL` is
203/// allowed and does nothing.
204///
205/// # Safety
206///
207/// `stream` must be a handle from [`enpose_pose_stream_connect`] that has
208/// not already been freed.
209#[unsafe(no_mangle)]
210pub unsafe extern "C" fn enpose_pose_stream_free(stream: *mut PoseStream) {
211 if stream.is_null() {
212 return;
213 }
214 // Dropping the box runs PoseStream's destructor, which disconnects.
215 let _ = catch_unwind(AssertUnwindSafe(|| drop(unsafe { Box::from_raw(stream) })));
216}
217
218/// Convert a [`DeviceInfo`] into its C representation.
219fn device_to_c(info: &DeviceInfo) -> EnposeDeviceInfo {
220 let mut ip = [0 as c_char; IP_BUF_LEN];
221 let text = info.ip.to_string();
222 let bytes = text.as_bytes();
223 // Leave at least one byte for the null terminator (already zero).
224 let n = bytes.len().min(IP_BUF_LEN - 1);
225 for (slot, &byte) in ip.iter_mut().zip(&bytes[..n]) {
226 *slot = byte as c_char;
227 }
228 EnposeDeviceInfo {
229 ip,
230 serial: info.serial,
231 compatible: info.compatible,
232 }
233}
234
235/// Leak a `Vec<T>` into a `(ptr, len)` pair the caller owns. An empty vector
236/// yields `(NULL, 0)` so the C side never sees a dangling pointer.
237fn leak_array<T>(items: Vec<T>) -> (*mut T, usize) {
238 if items.is_empty() {
239 return (ptr::null_mut(), 0);
240 }
241 let boxed = items.into_boxed_slice();
242 let count = boxed.len();
243 let ptr = boxed.as_ptr() as *mut T;
244 std::mem::forget(boxed);
245 (ptr, count)
246}
247
248/// Reconstruct and drop an array previously produced by [`leak_array`].
249///
250/// # Safety
251///
252/// `ptr`/`count` must be a pair from [`leak_array`] that has not been freed,
253/// or `(NULL, 0)`.
254unsafe fn free_array<T>(ptr: *mut T, count: usize) {
255 if ptr.is_null() || count == 0 {
256 return;
257 }
258 unsafe {
259 drop(Box::from_raw(std::slice::from_raw_parts_mut(ptr, count)));
260 }
261}
262
263#[cfg(test)]
264#[path = "ffi_tests.rs"]
265mod tests;