Skip to main content

mx_remote_ffi/
abi.rs

1// Author: Lars Op den Kamp (lars@opdenkamp-it.nl)
2// Copyright (c) 2026 Op den Kamp IT Solutions
3
4//! What every entry point shares: the result code, the identifiers, and the
5//! guard that keeps an unwind out of C.
6
7use std::any::Any;
8use std::cell::RefCell;
9use std::ffi::{c_char, CStr, CString};
10use std::io;
11use std::panic::{catch_unwind, AssertUnwindSafe};
12use std::str::FromStr;
13
14use mx_remote::{BayUid, ControlError, DeviceUid, SendError};
15
16/// Bytes a [`mxr_uid_t`] needs when written as text, the terminator included.
17pub const MXR_UID_STRING_LEN: usize = 36;
18
19/// How a call ended.
20///
21/// Everything but [`mxr_result_t::MXR_OK`] is negative, so `if (rc < 0)` is a
22/// complete test and a new code cannot turn a failure into a success.
23#[repr(i32)]
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum mxr_result_t {
26    /// The call did what was asked.
27    MXR_OK = 0,
28    /// A pointer was null, a buffer too small, or a string not UTF-8.
29    MXR_ERR_INVALID_ARGUMENT = -1,
30    /// No device, bay or source by that name has been heard from.
31    ///
32    /// A device reports itself when it feels like it, so this is as likely to
33    /// mean "not yet" as "never": the same call may succeed later.
34    MXR_ERR_NOT_FOUND = -2,
35    /// The addressed device speaks a protocol older than the command needs.
36    ///
37    /// It would discard the frame without answering, so nothing was sent.
38    MXR_ERR_PROTOCOL_TOO_OLD = -3,
39    /// The client has no socket, because it was never started or was closed.
40    MXR_ERR_NOT_CONNECTED = -4,
41    /// The socket write failed, or the socket could not be opened.
42    MXR_ERR_IO = -5,
43    /// The addressee does not do what was asked of it.
44    MXR_ERR_UNSUPPORTED = -6,
45    /// The device has not reported something the request is assembled from.
46    MXR_ERR_NOT_REPORTED = -7,
47    /// A panic was caught at the boundary. The library's state is unknown.
48    MXR_ERR_PANIC = -8,
49}
50
51/// A flag a device may not have reported.
52///
53/// Firmware sends only what it has, so "off" and "never said" are different
54/// answers and a two-valued flag would have to pick one of them for both.
55#[repr(i8)]
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum mxr_tribool_t {
58    /// The device has not reported this.
59    MXR_UNKNOWN = -1,
60    /// Reported, and false.
61    MXR_FALSE = 0,
62    /// Reported, and true.
63    MXR_TRUE = 1,
64}
65
66impl From<Option<bool>> for mxr_tribool_t {
67    fn from(value: Option<bool>) -> Self {
68        match value {
69            None => Self::MXR_UNKNOWN,
70            Some(false) => Self::MXR_FALSE,
71            Some(true) => Self::MXR_TRUE,
72        }
73    }
74}
75
76/// The 16-byte identifier of a device on the network.
77///
78/// All zero is the empty identifier, which is how the protocol says "no
79/// device" - see `mxr_uid_is_zero()`.
80#[repr(C)]
81#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
82pub struct mxr_uid_t {
83    /// The raw identifier, in wire order.
84    pub bytes: [u8; 16],
85}
86
87/// A single bay: the device it is on, and its port number there.
88#[repr(C)]
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90pub struct mxr_bay_uid_t {
91    /// The device the bay belongs to.
92    pub device: mxr_uid_t,
93    /// The bay's port number on that device.
94    pub port: u16,
95}
96
97impl From<DeviceUid> for mxr_uid_t {
98    fn from(uid: DeviceUid) -> Self {
99        Self {
100            bytes: *uid.as_bytes(),
101        }
102    }
103}
104
105impl From<mxr_uid_t> for DeviceUid {
106    fn from(uid: mxr_uid_t) -> Self {
107        DeviceUid::from_array(uid.bytes)
108    }
109}
110
111impl From<BayUid> for mxr_bay_uid_t {
112    fn from(bay: BayUid) -> Self {
113        Self {
114            device: bay.device.into(),
115            port: bay.port,
116        }
117    }
118}
119
120impl From<mxr_bay_uid_t> for BayUid {
121    fn from(bay: mxr_bay_uid_t) -> Self {
122        BayUid::new(bay.device.into(), bay.port)
123    }
124}
125
126/// The bay a route names, where the zero device stands for "unrouted".
127///
128/// The protocol already spends the zero identifier on absence, so a separate
129/// present flag would give the same fact two spellings that could disagree.
130pub(crate) fn bay_or_zero(bay: Option<BayUid>) -> mxr_bay_uid_t {
131    bay.map(mxr_bay_uid_t::from).unwrap_or_default()
132}
133
134thread_local! {
135    /// Why the last call on this thread failed. Kept per thread so a failure
136    /// on the receive thread cannot overwrite one the caller is about to read.
137    static LAST_ERROR: RefCell<CString> = RefCell::new(c"".to_owned());
138}
139
140/// Records why a call failed, for `mxr_last_error()`.
141pub(crate) fn set_last_error(message: &str) {
142    // A NUL inside the text would cut the message short in C, so a message
143    // that cannot be carried whole is replaced rather than truncated.
144    let text = CString::new(message).unwrap_or_else(|_| c"error text contains a NUL".to_owned());
145    LAST_ERROR.with(|slot| *slot.borrow_mut() = text);
146}
147
148/// Runs `body`, turning a panic into `fallback`.
149///
150/// Unwinding into C is undefined behaviour, so this sits inside every entry
151/// point. It is the only thing here that can absorb a bug rather than report
152/// it, which is why the panic message is kept: without it the caller would
153/// have a result code and no way to find out what happened.
154pub(crate) fn guard<T>(fallback: T, body: impl FnOnce() -> T) -> T {
155    match catch_unwind(AssertUnwindSafe(body)) {
156        Ok(value) => value,
157        Err(payload) => {
158            set_last_error(&format!("panic: {}", panic_text(&payload)));
159            fallback
160        }
161    }
162}
163
164/// The message a panic carried, for the two payload types `panic!` produces.
165fn panic_text(payload: &Box<dyn Any + Send>) -> &str {
166    if let Some(s) = payload.downcast_ref::<&str>() {
167        return s;
168    }
169    if let Some(s) = payload.downcast_ref::<String>() {
170        return s;
171    }
172    "no message"
173}
174
175/// Reports a failure and returns its result code.
176pub(crate) fn fail(code: mxr_result_t, message: &str) -> mxr_result_t {
177    set_last_error(message);
178    code
179}
180
181/// Turns a control failure into a result code, keeping its message.
182pub(crate) fn from_control(result: Result<(), ControlError>) -> mxr_result_t {
183    let error = match result {
184        Ok(()) => return mxr_result_t::MXR_OK,
185        Err(e) => e,
186    };
187    let code = match &error {
188        ControlError::UnknownDevice(_)
189        | ControlError::UnknownBay(_)
190        | ControlError::UnknownSource(_) => mxr_result_t::MXR_ERR_NOT_FOUND,
191        ControlError::Unsupported(_) => mxr_result_t::MXR_ERR_UNSUPPORTED,
192        // The caller's to fix, not the device's, so it reads as an argument
193        // error rather than as something the addressee cannot do.
194        ControlError::InvalidRequest(_) => mxr_result_t::MXR_ERR_INVALID_ARGUMENT,
195        ControlError::NotReported(_) => mxr_result_t::MXR_ERR_NOT_REPORTED,
196        ControlError::Send(e) => send_code(e),
197        // ControlError is non_exhaustive: an unnamed variant is a failure
198        // whose kind this build has no code for, never a success.
199        _ => mxr_result_t::MXR_ERR_UNSUPPORTED,
200    };
201    fail(code, &error.to_string())
202}
203
204/// Turns a send failure into a result code, keeping its message.
205pub(crate) fn from_send(result: Result<(), SendError>) -> mxr_result_t {
206    match result {
207        Ok(()) => mxr_result_t::MXR_OK,
208        Err(e) => fail(send_code(&e), &e.to_string()),
209    }
210}
211
212fn send_code(error: &SendError) -> mxr_result_t {
213    match error {
214        SendError::ProtocolTooOld { .. } => mxr_result_t::MXR_ERR_PROTOCOL_TOO_OLD,
215        SendError::NotConnected => mxr_result_t::MXR_ERR_NOT_CONNECTED,
216        SendError::Io(_) => mxr_result_t::MXR_ERR_IO,
217        SendError::UnknownOpcode { .. } => mxr_result_t::MXR_ERR_UNSUPPORTED,
218        _ => mxr_result_t::MXR_ERR_IO,
219    }
220}
221
222/// Turns an I/O failure into a result code, keeping its message.
223pub(crate) fn from_io(result: io::Result<()>) -> mxr_result_t {
224    match result {
225        Ok(()) => mxr_result_t::MXR_OK,
226        Err(e) => fail(mxr_result_t::MXR_ERR_IO, &e.to_string()),
227    }
228}
229
230/// Copies `text` into a fixed-width field, NUL-terminated and NUL-padded.
231///
232/// A field too narrow for the value truncates it on a character boundary
233/// rather than failing the call: these are display names, and a caller reading
234/// a device list would rather have a shortened name than no device.
235pub(crate) fn put_str(dst: &mut [c_char], text: &str) {
236    let room = dst.len().saturating_sub(1);
237    let mut end = room.min(text.len());
238    while end > 0 && !text.is_char_boundary(end) {
239        end -= 1;
240    }
241    let taken = text.as_bytes().get(..end).unwrap_or_default();
242    for (slot, byte) in dst.iter_mut().zip(taken) {
243        *slot = *byte as c_char;
244    }
245    for slot in dst.iter_mut().skip(end) {
246        *slot = 0;
247    }
248}
249
250/// Reads a caller's string, treating null as absent.
251///
252/// # Safety
253///
254/// `ptr` is null or points at a NUL-terminated string that outlives the call.
255pub(crate) unsafe fn opt_str<'a>(ptr: *const c_char) -> Result<Option<&'a str>, mxr_result_t> {
256    if ptr.is_null() {
257        return Ok(None);
258    }
259    // SAFETY: the caller guarantees a NUL-terminated string.
260    match unsafe { CStr::from_ptr(ptr) }.to_str() {
261        Ok(s) => Ok(Some(s)),
262        Err(_) => Err(fail(
263            mxr_result_t::MXR_ERR_INVALID_ARGUMENT,
264            "string is not valid UTF-8",
265        )),
266    }
267}
268
269/// Reads a caller's string, where absence is not allowed.
270///
271/// # Safety
272///
273/// `ptr` is null or points at a NUL-terminated string that outlives the call.
274pub(crate) unsafe fn req_str<'a>(ptr: *const c_char) -> Result<&'a str, mxr_result_t> {
275    // SAFETY: same contract as opt_str, which this only narrows.
276    match unsafe { opt_str(ptr) }? {
277        Some(s) => Ok(s),
278        None => Err(fail(
279            mxr_result_t::MXR_ERR_INVALID_ARGUMENT,
280            "a required string argument was null",
281        )),
282    }
283}
284
285/// This library's version, as `MAJOR.MINOR.PATCH`.
286///
287/// The returned pointer is static and always valid.
288#[no_mangle]
289pub extern "C" fn mxr_version() -> *const c_char {
290    // A literal, so there is nothing to fail and nothing to guard.
291    concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char
292}
293
294/// Why the last call on this thread failed, or an empty string.
295///
296/// The text is owned by the library and is replaced by the next failure on
297/// this thread, so a caller that keeps it copies it first. It describes the
298/// failure; the result code classifies it, and only the code should be
299/// branched on.
300///
301/// Never returns null.
302#[no_mangle]
303pub extern "C" fn mxr_last_error() -> *const c_char {
304    // Borrowing to take the pointer and then dropping the borrow is what the
305    // caller does anyway: the string lives in the thread-local, not the guard.
306    LAST_ERROR.with(|slot| slot.borrow().as_ptr())
307}
308
309/// Reports whether `uid` is the empty identifier.
310///
311/// The protocol uses it wherever a device could be named and is not, so this
312/// is the test for "no device" rather than a comparison against a constant.
313#[no_mangle]
314pub extern "C" fn mxr_uid_is_zero(uid: mxr_uid_t) -> bool {
315    uid.bytes == [0; 16]
316}
317
318/// Writes `uid` as dotted hex into `out`, which needs
319/// [`MXR_UID_STRING_LEN`] bytes.
320///
321/// # Safety
322///
323/// `out` points at `cap` writable bytes.
324#[no_mangle]
325pub unsafe extern "C" fn mxr_uid_to_string(
326    uid: mxr_uid_t,
327    out: *mut c_char,
328    cap: usize,
329) -> mxr_result_t {
330    guard(mxr_result_t::MXR_ERR_PANIC, || {
331        if out.is_null() || cap < MXR_UID_STRING_LEN {
332            return fail(
333                mxr_result_t::MXR_ERR_INVALID_ARGUMENT,
334                "uid buffer is null or shorter than MXR_UID_STRING_LEN",
335            );
336        }
337        // SAFETY: the caller guarantees cap writable bytes at out.
338        let dst = unsafe { std::slice::from_raw_parts_mut(out, cap) };
339        put_str(dst, &DeviceUid::from(uid).to_string());
340        mxr_result_t::MXR_OK
341    })
342}
343
344/// Reads the dotted-hex form `mxr_uid_to_string()` writes.
345///
346/// # Safety
347///
348/// `text` points at a NUL-terminated string and `out` at a writable
349/// [`mxr_uid_t`].
350#[no_mangle]
351pub unsafe extern "C" fn mxr_uid_from_string(
352    text: *const c_char,
353    out: *mut mxr_uid_t,
354) -> mxr_result_t {
355    guard(mxr_result_t::MXR_ERR_PANIC, || {
356        if out.is_null() {
357            return fail(
358                mxr_result_t::MXR_ERR_INVALID_ARGUMENT,
359                "uid output pointer is null",
360            );
361        }
362        // SAFETY: the caller guarantees a NUL-terminated string or null.
363        let text = match unsafe { req_str(text) } {
364            Ok(s) => s,
365            Err(code) => return code,
366        };
367        match DeviceUid::from_str(text) {
368            Ok(uid) => {
369                // SAFETY: checked non-null above, and mxr_uid_t is plain bytes.
370                unsafe { *out = uid.into() };
371                mxr_result_t::MXR_OK
372            }
373            Err(e) => fail(mxr_result_t::MXR_ERR_INVALID_ARGUMENT, &e.to_string()),
374        }
375    })
376}