From cb16b764cc172307be4bb19b04d91cd373bb6f9d Mon Sep 17 00:00:00 2001 From: Filip Jeretina Date: Wed, 29 Jul 2026 18:40:30 +0200 Subject: [PATCH 1/2] rust bindings for XLink discovery Signed-off-by: Filip Jeretina --- .github/workflows/rust-bindings.yml | 82 ++++++ rust/.cargo/config.toml | 5 + rust/.gitignore | 4 + rust/Cargo.toml | 13 + rust/README.md | 33 +++ rust/xlink-sys/Cargo.toml | 19 ++ rust/xlink-sys/build.rs | 193 ++++++++++++++ rust/xlink-sys/src/lib.rs | 172 ++++++++++++ rust/xlink/Cargo.toml | 17 ++ rust/xlink/examples/list_devices.rs | 13 + rust/xlink/src/lib.rs | 396 ++++++++++++++++++++++++++++ 11 files changed, 947 insertions(+) create mode 100644 .github/workflows/rust-bindings.yml create mode 100644 rust/.cargo/config.toml create mode 100644 rust/.gitignore create mode 100644 rust/Cargo.toml create mode 100644 rust/README.md create mode 100644 rust/xlink-sys/Cargo.toml create mode 100644 rust/xlink-sys/build.rs create mode 100644 rust/xlink-sys/src/lib.rs create mode 100644 rust/xlink/Cargo.toml create mode 100644 rust/xlink/examples/list_devices.rs create mode 100644 rust/xlink/src/lib.rs diff --git a/.github/workflows/rust-bindings.yml b/.github/workflows/rust-bindings.yml new file mode 100644 index 0000000..4635cc8 --- /dev/null +++ b/.github/workflows/rust-bindings.yml @@ -0,0 +1,82 @@ +name: Rust bindings + +on: + push: + tags: + - 'bindings/v*' + pull_request: + paths: + - 'rust/**' + - 'src/**' + - 'include/**' + - '.github/workflows/rust-bindings.yml' + +jobs: + test: + name: Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Format + run: cargo fmt --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Test + run: cargo test + + publish: + name: Publish to crates.int.luxonis.com + if: startsWith(github.ref, 'refs/tags/bindings/v') + needs: test + runs-on: ubuntu-latest + defaults: + run: + working-directory: rust + env: + CARGO_REGISTRIES_LUXONIS_TOKEN: ${{ secrets.CARGO_REGISTRIES_LUXONIS_TOKEN }} + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Check that the crate version matches the tag + run: | + version="${GITHUB_REF_NAME#bindings/v}" + grep -q "^version = \"${version}\"$" Cargo.toml || { + echo "Tag ${GITHUB_REF_NAME} does not match workspace version:" + grep "^version = " Cargo.toml + exit 1 + } + + - name: Vendor XLink C/C++ sources into xlink-sys + # A published .crate archive can only contain files inside the crate + # directory; xlink-sys/build.rs prefers this vendored copy over ../../ + run: | + mkdir -p xlink-sys/xlink-src + cp -r ../src ../include xlink-sys/xlink-src/ + + - name: Publish xlink-sys + run: cargo publish -p xlink-sys --registry luxonis --allow-dirty + + - name: Publish xlink + # Retry a few times, the just-published xlink-sys may take a moment + # to appear in the registry index. + run: | + for i in $(seq 1 5); do + cargo publish -p xlink --registry luxonis --allow-dirty && exit 0 + echo "Publish attempt ${i} failed, retrying in 15s..." + sleep 15 + done + exit 1 diff --git a/rust/.cargo/config.toml b/rust/.cargo/config.toml new file mode 100644 index 0000000..779bb70 --- /dev/null +++ b/rust/.cargo/config.toml @@ -0,0 +1,5 @@ +[registry] +global-credential-providers = ["cargo:token", "cargo:libsecret", "cargo:macos-keychain", "cargo:wincred"] + +[registries.luxonis] +index = "sparse+https://crates.int.luxonis.com/api/v1/crates/" diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..f61afeb --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1,4 @@ +/target +Cargo.lock +# vendored by CI when publishing, see .github/workflows/rust-bindings.yml +xlink-sys/xlink-src/ diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..8d952f7 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] +resolver = "2" +members = ["xlink-sys", "xlink"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/luxonis/XLink" +# Only publish to the internal Luxonis registry (never crates.io). +# Publishing is done by CI on `bindings/vX.Y.Z` tags, see +# .github/workflows/rust-bindings.yml +publish = ["luxonis"] diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..84e61f3 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,33 @@ +# XLink Rust bindings + +Rust bindings to XLink, split into two crates: + +- `xlink-sys` — raw FFI bindings. Compiles the XLink C/C++ sources from this + repository directly via the [`cc`](https://crates.io/crates/cc) crate (no + CMake or Hunter required). By default the USB (libusb) protocol is disabled, + so only TCP/IP discovery is available and there are no external native + dependencies. Enable the `libusb` feature to compile the USB protocol + (requires libusb-1.0, discovered via pkg-config). +- `xlink` — safe, idiomatic wrapper. Currently covers library initialization + and device discovery (`find_devices`), which is enough to enumerate RVC2 + devices over the network (and over USB with the `libusb` feature). + +## Usage + +```toml +[dependencies] +xlink = { git = "https://github.com/luxonis/XLink" } +``` + +```rust +let devices = xlink::find_tcpip_devices()?; +for device in devices { + println!("{} {} ({})", device.mxid, device.name, device.state); +} +``` + +## Example + +```sh +cargo run --example list_devices +``` diff --git a/rust/xlink-sys/Cargo.toml b/rust/xlink-sys/Cargo.toml new file mode 100644 index 0000000..f20382b --- /dev/null +++ b/rust/xlink-sys/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "xlink-sys" +description = "Raw FFI bindings to the XLink library, built from source" +links = "XLink" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[features] +default = [] +# Enable the USB (VSC) protocol. Requires libusb-1.0 to be available on the +# system (discovered via pkg-config on unix). +libusb = [] + +[build-dependencies] +cc = { version = "1.2", features = ["parallel"] } +pkg-config = "0.3" diff --git a/rust/xlink-sys/build.rs b/rust/xlink-sys/build.rs new file mode 100644 index 0000000..9cd298d --- /dev/null +++ b/rust/xlink-sys/build.rs @@ -0,0 +1,193 @@ +use std::env; +use std::path::{Path, PathBuf}; + +/// Collects sources with the given extension from a directory (non recursive), +/// mirroring the file(GLOB ...) logic from cmake/XLink.cmake. +fn glob_sources(dir: &Path, extension: &str, exclude: &[&str]) -> Vec { + let mut sources: Vec = std::fs::read_dir(dir) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display())) + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == extension)) + .filter(|path| { + let name = path.file_name().unwrap().to_string_lossy(); + !exclude.contains(&name.as_ref()) + }) + .collect(); + sources.sort(); + sources +} + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + // When building from a published crate, the C/C++ sources are vendored + // into `xlink-src/` (see .github/workflows/rust-bindings.yml). When + // building from the repository, they live at the repository root. + let vendored = manifest_dir.join("xlink-src"); + let root = if vendored.join("src").is_dir() { + vendored + } else { + // /rust/xlink-sys -> + manifest_dir + .ancestors() + .nth(2) + .expect("xlink-sys must live in /rust/xlink-sys") + .to_path_buf() + }; + + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); + let target_env = env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + let libusb_enabled = env::var("CARGO_FEATURE_LIBUSB").is_ok(); + + let src_pc = root.join("src/pc"); + let src_protocols = root.join("src/pc/protocols"); + let src_shared = root.join("src/shared"); + + println!("cargo:rerun-if-changed={}", root.join("src").display()); + println!("cargo:rerun-if-changed={}", root.join("include").display()); + + let mut usb_excludes: Vec<&str> = Vec::new(); + if !libusb_enabled { + // Same removal as cmake/XLink.cmake when XLINK_ENABLE_LIBUSB=OFF + usb_excludes.push("usb_host.cpp"); + usb_excludes.push("win_usb_host.cpp"); + } + + let mut c_sources = Vec::new(); + let mut cpp_sources = Vec::new(); + + c_sources.extend(glob_sources(&src_pc, "c", &usb_excludes)); + cpp_sources.extend(glob_sources(&src_pc, "cpp", &usb_excludes)); + c_sources.extend(glob_sources(&src_protocols, "c", &usb_excludes)); + cpp_sources.extend(glob_sources(&src_protocols, "cpp", &usb_excludes)); + // file(GLOB_RECURSE ...) on src/shared; the directory is currently flat + c_sources.extend(glob_sources(&src_shared, "c", &[])); + cpp_sources.extend(glob_sources(&src_shared, "cpp", &[])); + + let mut platform_include: Option = None; + match target_os.as_str() { + "windows" => { + platform_include = Some(root.join("src/pc/Win/include")); + c_sources.extend(glob_sources( + &root.join("src/pc/Win/src"), + "c", + &usb_excludes, + )); + cpp_sources.extend(glob_sources( + &root.join("src/pc/Win/src"), + "cpp", + &usb_excludes, + )); + } + "macos" | "ios" => { + platform_include = Some(root.join("src/pc/MacOS")); + c_sources.push(root.join("src/pc/MacOS/pthread_semaphore.c")); + } + _ => {} + } + + // Stub for the header normally produced by CMake's generate_export_header. + // We always build a static library, so the macros expand to nothing. + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let export_header_dir = out_dir.join("generated-include"); + std::fs::create_dir_all(&export_header_dir).unwrap(); + std::fs::write( + export_header_dir.join("XLinkExport.h"), + "#ifndef XLINK_EXPORT_H\n\ + #define XLINK_EXPORT_H\n\ + #define XLINK_EXPORT\n\ + #define XLINK_NO_EXPORT\n\ + #define XLINK_DEPRECATED\n\ + #endif\n", + ) + .unwrap(); + + let configure_common = |build: &mut cc::Build| { + build + .include(root.join("include")) + .include(root.join("include/XLink")) + .include(&export_header_dir) + .include(&src_protocols) + .define("HAVE_STRUCT_TIMESPEC", None) + .define("_CRT_SECURE_NO_WARNINGS", None) + .define("USE_USB_VSC", None) + .define("USE_TCP_IP", None) + .warnings(false); + + if let Some(include) = &platform_include { + build.include(include); + } + + if libusb_enabled { + build.define("XLINK_ENABLE_LIBUSB", None); + } + + match target_os.as_str() { + "windows" => { + build.define("WIN32_LEAN_AND_MEAN", None); + } + "linux" | "android" => { + build.define("_GNU_SOURCE", None); + // pthread_getname_np is available on glibc >= 2.12; musl gained + // it in 1.2.3 but keep the conservative glibc-only default. + if target_env == "gnu" { + build.define("HAVE_PTHREAD_GETNAME_NP", None); + } + } + "macos" | "ios" => { + build.define("HAVE_PTHREAD_GETNAME_NP", None); + } + _ => {} + } + }; + + let mut usb_include: Option = None; + if libusb_enabled { + #[allow(unused_mut)] + let mut probed = pkg_config::Config::new() + .atleast_version("1.0") + .probe("libusb-1.0"); + match probed { + Ok(library) => { + usb_include = library.include_paths.first().cloned(); + } + Err(err) => { + panic!("the `libusb` feature requires libusb-1.0 (pkg-config lookup failed: {err})") + } + } + } + + // C sources (C99, gnu extensions like the CMake build) + let mut c_build = cc::Build::new(); + configure_common(&mut c_build); + if let Some(include) = &usb_include { + c_build.include(include); + } + c_build.std("gnu99").files(&c_sources).compile("xlink_c"); + + // C++ sources (C++11) + let mut cpp_build = cc::Build::new(); + configure_common(&mut cpp_build); + if let Some(include) = &usb_include { + cpp_build.include(include); + } + cpp_build + .cpp(true) + .std("c++11") + .files(&cpp_sources) + .compile("xlink_cpp"); + + match target_os.as_str() { + "windows" => { + println!("cargo:rustc-link-lib=ws2_32"); + println!("cargo:rustc-link-lib=iphlpapi"); + } + "macos" | "ios" => {} + "android" => { + println!("cargo:rustc-link-lib=log"); + } + _ => { + println!("cargo:rustc-link-lib=pthread"); + } + } +} diff --git a/rust/xlink-sys/src/lib.rs b/rust/xlink-sys/src/lib.rs new file mode 100644 index 0000000..5b2a33f --- /dev/null +++ b/rust/xlink-sys/src/lib.rs @@ -0,0 +1,172 @@ +//! Raw FFI bindings to the XLink library. +//! +//! The declarations mirror `include/XLink/XLink.h` and +//! `include/XLink/XLinkPublicDefines.h`. Enum values are represented as plain +//! integers (`c_int` newtypes via type aliases + constants) so that unknown +//! values coming from the C side never cause undefined behaviour. + +#![allow(non_camel_case_types, non_snake_case, non_upper_case_globals)] + +use std::os::raw::{c_char, c_int, c_ulong, c_void}; + +pub const XLINK_MAX_MX_ID_SIZE: usize = 32; +pub const XLINK_MAX_NAME_SIZE: usize = 64; + +// XLinkError_t +pub type XLinkError_t = c_int; +pub const X_LINK_SUCCESS: XLinkError_t = 0; +pub const X_LINK_ALREADY_OPEN: XLinkError_t = 1; +pub const X_LINK_COMMUNICATION_NOT_OPEN: XLinkError_t = 2; +pub const X_LINK_COMMUNICATION_FAIL: XLinkError_t = 3; +pub const X_LINK_COMMUNICATION_UNKNOWN_ERROR: XLinkError_t = 4; +pub const X_LINK_DEVICE_NOT_FOUND: XLinkError_t = 5; +pub const X_LINK_TIMEOUT: XLinkError_t = 6; +pub const X_LINK_ERROR: XLinkError_t = 7; +pub const X_LINK_OUT_OF_MEMORY: XLinkError_t = 8; +pub const X_LINK_INSUFFICIENT_PERMISSIONS: XLinkError_t = 9; +pub const X_LINK_DEVICE_ALREADY_IN_USE: XLinkError_t = 10; +pub const X_LINK_NOT_IMPLEMENTED: XLinkError_t = 11; +pub const X_LINK_INIT_USB_ERROR: XLinkError_t = 12; +pub const X_LINK_INIT_TCP_IP_ERROR: XLinkError_t = 13; +pub const X_LINK_INIT_PCIE_ERROR: XLinkError_t = 14; + +// XLinkProtocol_t +pub type XLinkProtocol_t = c_int; +pub const X_LINK_USB_VSC: XLinkProtocol_t = 0; +pub const X_LINK_USB_CDC: XLinkProtocol_t = 1; +pub const X_LINK_PCIE: XLinkProtocol_t = 2; +pub const X_LINK_IPC: XLinkProtocol_t = 3; +pub const X_LINK_TCP_IP: XLinkProtocol_t = 4; +pub const X_LINK_NMB_OF_PROTOCOLS: XLinkProtocol_t = 5; +pub const X_LINK_ANY_PROTOCOL: XLinkProtocol_t = 6; + +// XLinkPlatform_t +pub type XLinkPlatform_t = c_int; +pub const X_LINK_ANY_PLATFORM: XLinkPlatform_t = 0; +pub const X_LINK_MYRIAD_2: XLinkPlatform_t = 2450; +pub const X_LINK_MYRIAD_X: XLinkPlatform_t = 2480; + +// XLinkDeviceState_t +pub type XLinkDeviceState_t = c_int; +pub const X_LINK_ANY_STATE: XLinkDeviceState_t = 0; +pub const X_LINK_BOOTED: XLinkDeviceState_t = 1; +pub const X_LINK_UNBOOTED: XLinkDeviceState_t = 2; +pub const X_LINK_BOOTLOADER: XLinkDeviceState_t = 3; +pub const X_LINK_FLASH_BOOTED: XLinkDeviceState_t = 4; +pub const X_LINK_BOOTED_NON_EXCLUSIVE: XLinkDeviceState_t = X_LINK_FLASH_BOOTED; + +// mvLog_t +pub type mvLog_t = c_int; +pub const MVLOG_DEBUG: mvLog_t = 0; +pub const MVLOG_INFO: mvLog_t = 1; +pub const MVLOG_WARN: mvLog_t = 2; +pub const MVLOG_ERROR: mvLog_t = 3; +pub const MVLOG_FATAL: mvLog_t = 4; +pub const MVLOG_LAST: mvLog_t = 5; + +#[repr(C)] +#[derive(Copy, Clone)] +pub struct deviceDesc_t { + pub protocol: XLinkProtocol_t, + pub platform: XLinkPlatform_t, + pub name: [c_char; XLINK_MAX_NAME_SIZE], + pub state: XLinkDeviceState_t, + pub mxid: [c_char; XLINK_MAX_MX_ID_SIZE], + pub status: XLinkError_t, + pub nameHintOnly: bool, +} + +impl Default for deviceDesc_t { + fn default() -> Self { + // Equivalent of `deviceDesc_t desc = {};` in C + unsafe { std::mem::zeroed() } + } +} + +#[repr(C)] +#[derive(Copy, Clone, Default)] +pub struct XLinkProf_t { + pub totalReadTime: f32, + pub totalWriteTime: f32, + pub totalReadBytes: u64, + pub totalWriteBytes: u64, + pub totalBootCount: c_ulong, + pub totalBootTime: f32, +} + +#[repr(C)] +#[derive(Copy, Clone)] +pub struct XLinkGlobalHandler_t { + pub profEnable: c_int, + pub profilingData: XLinkProf_t, + pub options: *mut c_void, + // Deprecated fields + pub loglevel: c_int, + pub protocol: c_int, +} + +impl Default for XLinkGlobalHandler_t { + fn default() -> Self { + unsafe { std::mem::zeroed() } + } +} + +unsafe extern "C" { + /// Initializes XLink and the scheduler. Safe to call multiple times. + /// The passed handler must outlive all XLink usage (it is stored globally). + pub fn XLinkInitialize(globalHandler: *mut XLinkGlobalHandler_t) -> XLinkError_t; + + pub fn XLinkIsProtocolInitialized(protocol: XLinkProtocol_t) -> c_int; + + pub fn XLinkFindFirstSuitableDevice( + in_deviceRequirements: deviceDesc_t, + out_foundDevice: *mut deviceDesc_t, + ) -> XLinkError_t; + + pub fn XLinkFindAllSuitableDevices( + in_deviceRequirements: deviceDesc_t, + out_foundDevicesPtr: *mut deviceDesc_t, + devicesArraySize: u32, + out_foundDevicesCount: *mut u32, + ) -> XLinkError_t; + + pub fn XLinkSearchForDevices( + in_deviceRequirements: deviceDesc_t, + out_foundDevicesPtr: *mut deviceDesc_t, + devicesArraySize: u32, + out_foundDevicesCount: *mut u32, + timeoutMs: c_int, + cb: Option bool>, + ) -> XLinkError_t; + + pub fn XLinkBootBootloader(deviceDesc: *const deviceDesc_t) -> XLinkError_t; + + pub fn XLinkErrorToStr(val: XLinkError_t) -> *const c_char; + pub fn XLinkProtocolToStr(val: XLinkProtocol_t) -> *const c_char; + pub fn XLinkPlatformToStr(val: XLinkPlatform_t) -> *const c_char; + pub fn XLinkDeviceStateToStr(val: XLinkDeviceState_t) -> *const c_char; + + /// Global default log level of the library (`MVLOGLEVEL(default)`). + pub static mut mvLogLevel_default: mvLog_t; + /// Global log level override (`MVLOGLEVEL(global)`). + pub static mut mvLogLevel_global: mvLog_t; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn device_desc_layout() { + // protocol(4) + platform(4) + name(64) + state(4) + mxid(32) + + // status(4) + nameHintOnly(1) + padding(3) + assert_eq!(std::mem::size_of::(), 116); + assert_eq!(std::mem::align_of::(), 4); + } + + #[test] + fn error_to_str_roundtrip() { + let s = unsafe { std::ffi::CStr::from_ptr(XLinkErrorToStr(X_LINK_DEVICE_NOT_FOUND)) }; + assert_eq!(s.to_str().unwrap(), "X_LINK_DEVICE_NOT_FOUND"); + } +} diff --git a/rust/xlink/Cargo.toml b/rust/xlink/Cargo.toml new file mode 100644 index 0000000..922bd0f --- /dev/null +++ b/rust/xlink/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "xlink" +description = "Safe Rust bindings to the XLink library (device discovery for Luxonis RVC2 devices)" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[features] +default = [] +# Enable the USB (VSC) protocol. Requires libusb-1.0 on the system. +libusb = ["xlink-sys/libusb"] + +[dependencies] +xlink-sys = { path = "../xlink-sys", version = "=0.1.0", registry = "luxonis" } +thiserror = "2" diff --git a/rust/xlink/examples/list_devices.rs b/rust/xlink/examples/list_devices.rs new file mode 100644 index 0000000..d81fd20 --- /dev/null +++ b/rust/xlink/examples/list_devices.rs @@ -0,0 +1,13 @@ +fn main() { + let devices = xlink::find_devices(&xlink::DeviceQuery::default()).expect("device search"); + if devices.is_empty() { + println!("No devices found."); + return; + } + for device in devices { + println!( + "name: {}, mxid: {}, state: {}, protocol: {:?}, platform: {:?}", + device.name, device.mxid, device.state, device.protocol, device.platform + ); + } +} diff --git a/rust/xlink/src/lib.rs b/rust/xlink/src/lib.rs new file mode 100644 index 0000000..fddc480 --- /dev/null +++ b/rust/xlink/src/lib.rs @@ -0,0 +1,396 @@ +//! Safe Rust bindings to the XLink library. +//! +//! Currently focused on device discovery (`find_devices`), which is what is +//! needed to enumerate RVC2 devices (USB and PoE/TCP-IP). +//! +//! ```no_run +//! let devices = xlink::find_devices(&xlink::DeviceQuery::default()).unwrap(); +//! for device in devices { +//! println!("{} {} ({:?})", device.mxid, device.name, device.state); +//! } +//! ``` + +use std::ffi::CStr; +use std::os::raw::c_char; +use std::sync::OnceLock; + +/// Errors returned by the XLink library, mirroring `XLinkError_t`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("X_LINK_ALREADY_OPEN")] + AlreadyOpen, + #[error("X_LINK_COMMUNICATION_NOT_OPEN")] + CommunicationNotOpen, + #[error("X_LINK_COMMUNICATION_FAIL")] + CommunicationFail, + #[error("X_LINK_COMMUNICATION_UNKNOWN_ERROR")] + CommunicationUnknownError, + #[error("X_LINK_DEVICE_NOT_FOUND")] + DeviceNotFound, + #[error("X_LINK_TIMEOUT")] + Timeout, + #[error("X_LINK_ERROR")] + Generic, + #[error("X_LINK_OUT_OF_MEMORY")] + OutOfMemory, + #[error("X_LINK_INSUFFICIENT_PERMISSIONS")] + InsufficientPermissions, + #[error("X_LINK_DEVICE_ALREADY_IN_USE")] + DeviceAlreadyInUse, + #[error("X_LINK_NOT_IMPLEMENTED")] + NotImplemented, + #[error("X_LINK_INIT_USB_ERROR")] + InitUsbError, + #[error("X_LINK_INIT_TCP_IP_ERROR")] + InitTcpIpError, + #[error("X_LINK_INIT_PCIE_ERROR")] + InitPcieError, + #[error("unknown XLink error code {0}")] + Unknown(i32), +} + +fn check(code: xlink_sys::XLinkError_t) -> Result<(), Error> { + match code { + xlink_sys::X_LINK_SUCCESS => Ok(()), + xlink_sys::X_LINK_ALREADY_OPEN => Err(Error::AlreadyOpen), + xlink_sys::X_LINK_COMMUNICATION_NOT_OPEN => Err(Error::CommunicationNotOpen), + xlink_sys::X_LINK_COMMUNICATION_FAIL => Err(Error::CommunicationFail), + xlink_sys::X_LINK_COMMUNICATION_UNKNOWN_ERROR => Err(Error::CommunicationUnknownError), + xlink_sys::X_LINK_DEVICE_NOT_FOUND => Err(Error::DeviceNotFound), + xlink_sys::X_LINK_TIMEOUT => Err(Error::Timeout), + xlink_sys::X_LINK_ERROR => Err(Error::Generic), + xlink_sys::X_LINK_OUT_OF_MEMORY => Err(Error::OutOfMemory), + xlink_sys::X_LINK_INSUFFICIENT_PERMISSIONS => Err(Error::InsufficientPermissions), + xlink_sys::X_LINK_DEVICE_ALREADY_IN_USE => Err(Error::DeviceAlreadyInUse), + xlink_sys::X_LINK_NOT_IMPLEMENTED => Err(Error::NotImplemented), + xlink_sys::X_LINK_INIT_USB_ERROR => Err(Error::InitUsbError), + xlink_sys::X_LINK_INIT_TCP_IP_ERROR => Err(Error::InitTcpIpError), + xlink_sys::X_LINK_INIT_PCIE_ERROR => Err(Error::InitPcieError), + other => Err(Error::Unknown(other)), + } +} + +/// Transport protocol of a device, mirroring `XLinkProtocol_t`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Protocol { + UsbVsc, + UsbCdc, + Pcie, + Ipc, + TcpIp, + Unknown(i32), +} + +impl Protocol { + fn from_raw(raw: xlink_sys::XLinkProtocol_t) -> Self { + match raw { + xlink_sys::X_LINK_USB_VSC => Self::UsbVsc, + xlink_sys::X_LINK_USB_CDC => Self::UsbCdc, + xlink_sys::X_LINK_PCIE => Self::Pcie, + xlink_sys::X_LINK_IPC => Self::Ipc, + xlink_sys::X_LINK_TCP_IP => Self::TcpIp, + other => Self::Unknown(other), + } + } + + fn to_raw(self) -> xlink_sys::XLinkProtocol_t { + match self { + Self::UsbVsc => xlink_sys::X_LINK_USB_VSC, + Self::UsbCdc => xlink_sys::X_LINK_USB_CDC, + Self::Pcie => xlink_sys::X_LINK_PCIE, + Self::Ipc => xlink_sys::X_LINK_IPC, + Self::TcpIp => xlink_sys::X_LINK_TCP_IP, + Self::Unknown(other) => other, + } + } +} + +/// Device platform, mirroring `XLinkPlatform_t`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Platform { + Myriad2, + MyriadX, + Unknown(i32), +} + +impl Platform { + fn from_raw(raw: xlink_sys::XLinkPlatform_t) -> Self { + match raw { + xlink_sys::X_LINK_MYRIAD_2 => Self::Myriad2, + xlink_sys::X_LINK_MYRIAD_X => Self::MyriadX, + other => Self::Unknown(other), + } + } + + fn to_raw(self) -> xlink_sys::XLinkPlatform_t { + match self { + Self::Myriad2 => xlink_sys::X_LINK_MYRIAD_2, + Self::MyriadX => xlink_sys::X_LINK_MYRIAD_X, + Self::Unknown(other) => other, + } + } +} + +/// Device state, mirroring `XLinkDeviceState_t`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DeviceState { + Booted, + Unbooted, + Bootloader, + FlashBooted, + Unknown(i32), +} + +impl DeviceState { + fn from_raw(raw: xlink_sys::XLinkDeviceState_t) -> Self { + match raw { + xlink_sys::X_LINK_BOOTED => Self::Booted, + xlink_sys::X_LINK_UNBOOTED => Self::Unbooted, + xlink_sys::X_LINK_BOOTLOADER => Self::Bootloader, + xlink_sys::X_LINK_FLASH_BOOTED => Self::FlashBooted, + other => Self::Unknown(other), + } + } + + fn to_raw(self) -> xlink_sys::XLinkDeviceState_t { + match self { + Self::Booted => xlink_sys::X_LINK_BOOTED, + Self::Unbooted => xlink_sys::X_LINK_UNBOOTED, + Self::Bootloader => xlink_sys::X_LINK_BOOTLOADER, + Self::FlashBooted => xlink_sys::X_LINK_FLASH_BOOTED, + Self::Unknown(other) => other, + } + } +} + +impl std::fmt::Display for DeviceState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Booted => write!(f, "booted"), + Self::Unbooted => write!(f, "unbooted"), + Self::Bootloader => write!(f, "bootloader"), + Self::FlashBooted => write!(f, "flash-booted"), + Self::Unknown(other) => write!(f, "unknown({other})"), + } + } +} + +/// A discovered device. +#[derive(Debug, Clone)] +pub struct DeviceInfo { + /// Device path: an IP address for TCP/IP devices, a USB path (e.g. + /// `1.4`) for USB devices. + pub name: String, + /// Device serial (MX ID), e.g. `14442C10D13EABCE00`. + pub mxid: String, + pub protocol: Protocol, + pub platform: Platform, + pub state: DeviceState, +} + +/// Search filter for [`find_devices`]. The default value matches any device. +#[derive(Debug, Clone, Default)] +pub struct DeviceQuery { + /// Only devices reachable over this protocol. + pub protocol: Option, + /// Only devices of this platform. + pub platform: Option, + /// Only devices in this state. + pub state: Option, + /// Exact device name (IP address / USB path) to look for. + pub name: Option, + /// Exact device MX ID to look for. + pub mxid: Option, +} + +/// Log verbosity of the underlying XLink library. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogLevel { + Debug, + Info, + Warn, + Error, + Fatal, + Off, +} + +/// Sets the global log level of the underlying XLink library. +/// XLink logs to stdout/stderr directly; the default level is `Error`. +pub fn set_log_level(level: LogLevel) { + let raw = match level { + LogLevel::Debug => xlink_sys::MVLOG_DEBUG, + LogLevel::Info => xlink_sys::MVLOG_INFO, + LogLevel::Warn => xlink_sys::MVLOG_WARN, + LogLevel::Error => xlink_sys::MVLOG_ERROR, + LogLevel::Fatal => xlink_sys::MVLOG_FATAL, + LogLevel::Off => xlink_sys::MVLOG_LAST, + }; + unsafe { + xlink_sys::mvLogLevel_default = raw; + } +} + +/// Initializes the XLink library (once per process). Called implicitly by +/// [`find_devices`]. +/// +/// Succeeds even when a protocol (e.g. USB without the `libusb` feature) is +/// unavailable; such protocols are simply skipped during discovery. +pub fn ensure_initialized() -> Result<(), Error> { + static INIT: OnceLock = OnceLock::new(); + let status = *INIT.get_or_init(|| { + // XLink stores the pointer globally, so the handler must be 'static. + let handler = Box::leak(Box::new(xlink_sys::XLinkGlobalHandler_t::default())); + unsafe { xlink_sys::XLinkInitialize(handler) } + }); + check(status) +} + +fn copy_to_c_array(dst: &mut [c_char], src: &str) { + // leave space for the NUL terminator + let capacity = dst.len() - 1; + for (dst_byte, src_byte) in dst.iter_mut().take(capacity).zip(src.as_bytes()) { + *dst_byte = *src_byte as c_char; + } +} + +fn c_array_to_string(src: &[c_char]) -> String { + let bytes: Vec = src + .iter() + .take_while(|&&byte| byte != 0) + .map(|&byte| byte as u8) + .collect(); + String::from_utf8_lossy(&bytes).into_owned() +} + +impl DeviceQuery { + fn to_raw(&self) -> xlink_sys::deviceDesc_t { + let mut desc = xlink_sys::deviceDesc_t { + protocol: self + .protocol + .map_or(xlink_sys::X_LINK_ANY_PROTOCOL, Protocol::to_raw), + platform: self + .platform + .map_or(xlink_sys::X_LINK_ANY_PLATFORM, Platform::to_raw), + state: self + .state + .map_or(xlink_sys::X_LINK_ANY_STATE, DeviceState::to_raw), + ..Default::default() + }; + if let Some(name) = &self.name { + copy_to_c_array(&mut desc.name, name); + } + if let Some(mxid) = &self.mxid { + copy_to_c_array(&mut desc.mxid, mxid); + } + desc + } +} + +/// Maximum number of devices returned by a single [`find_devices`] call. +pub const MAX_DEVICES: usize = 64; + +/// Finds all devices matching the query. +/// +/// This performs blocking I/O (UDP broadcast for TCP/IP devices with a ~half +/// second timeout, USB enumeration when built with the `libusb` feature). Call +/// it from a blocking-friendly context (e.g. `tokio::task::spawn_blocking`). +pub fn find_devices(query: &DeviceQuery) -> Result, Error> { + ensure_initialized()?; + + let requirements = query.to_raw(); + let mut found = [xlink_sys::deviceDesc_t::default(); MAX_DEVICES]; + let mut count: u32 = 0; + let result = check(unsafe { + xlink_sys::XLinkFindAllSuitableDevices( + requirements, + found.as_mut_ptr(), + found.len() as u32, + &mut count, + ) + }); + match result { + // Reported when a protocol-specific search matches nothing. + Err(Error::DeviceNotFound) => return Ok(Vec::new()), + other => other?, + } + + Ok(found[..count as usize] + .iter() + .map(|desc| DeviceInfo { + name: c_array_to_string(&desc.name), + mxid: c_array_to_string(&desc.mxid), + protocol: Protocol::from_raw(desc.protocol), + platform: Platform::from_raw(desc.platform), + state: DeviceState::from_raw(desc.state), + }) + .collect()) +} + +/// Convenience helper: finds all devices reachable over TCP/IP (e.g. RVC2 PoE +/// devices), regardless of state. +pub fn find_tcpip_devices() -> Result, Error> { + find_devices(&DeviceQuery { + protocol: Some(Protocol::TcpIp), + ..Default::default() + }) +} + +/// Returns the string representation of an error as reported by XLink itself. +pub fn error_to_str(error: Error) -> &'static str { + let raw = match error { + Error::AlreadyOpen => xlink_sys::X_LINK_ALREADY_OPEN, + Error::CommunicationNotOpen => xlink_sys::X_LINK_COMMUNICATION_NOT_OPEN, + Error::CommunicationFail => xlink_sys::X_LINK_COMMUNICATION_FAIL, + Error::CommunicationUnknownError => xlink_sys::X_LINK_COMMUNICATION_UNKNOWN_ERROR, + Error::DeviceNotFound => xlink_sys::X_LINK_DEVICE_NOT_FOUND, + Error::Timeout => xlink_sys::X_LINK_TIMEOUT, + Error::Generic => xlink_sys::X_LINK_ERROR, + Error::OutOfMemory => xlink_sys::X_LINK_OUT_OF_MEMORY, + Error::InsufficientPermissions => xlink_sys::X_LINK_INSUFFICIENT_PERMISSIONS, + Error::DeviceAlreadyInUse => xlink_sys::X_LINK_DEVICE_ALREADY_IN_USE, + Error::NotImplemented => xlink_sys::X_LINK_NOT_IMPLEMENTED, + Error::InitUsbError => xlink_sys::X_LINK_INIT_USB_ERROR, + Error::InitTcpIpError => xlink_sys::X_LINK_INIT_TCP_IP_ERROR, + Error::InitPcieError => xlink_sys::X_LINK_INIT_PCIE_ERROR, + Error::Unknown(_) => xlink_sys::X_LINK_ERROR, + }; + unsafe { CStr::from_ptr(xlink_sys::XLinkErrorToStr(raw)) } + .to_str() + .unwrap_or("X_LINK_ERROR") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn init_succeeds_without_devices() { + ensure_initialized().unwrap(); + } + + #[test] + fn tcpip_discovery_runs() { + // Should not error even when no devices are present on the network. + let devices = find_tcpip_devices().unwrap(); + for device in devices { + assert_eq!(device.protocol, Protocol::TcpIp); + } + } + + #[test] + fn query_roundtrip() { + let query = DeviceQuery { + protocol: Some(Protocol::TcpIp), + platform: Some(Platform::MyriadX), + state: Some(DeviceState::Bootloader), + name: Some("192.168.1.44".to_string()), + mxid: Some("14442C10D13EABCE00".to_string()), + }; + let raw = query.to_raw(); + assert_eq!(raw.protocol, xlink_sys::X_LINK_TCP_IP); + assert_eq!(raw.platform, xlink_sys::X_LINK_MYRIAD_X); + assert_eq!(raw.state, xlink_sys::X_LINK_BOOTLOADER); + assert_eq!(c_array_to_string(&raw.name), "192.168.1.44"); + assert_eq!(c_array_to_string(&raw.mxid), "14442C10D13EABCE00"); + } +} From 48b299accf1728bb78224ad90500f39f7c30f4c5 Mon Sep 17 00:00:00 2001 From: Filip Jeretina Date: Wed, 29 Jul 2026 18:48:16 +0200 Subject: [PATCH 2/2] ci: fix rust bindings publishing - restrict CI to the cargo:token credential provider; runners lack libsecret-1.so.0 which the provider list in rust/.cargo/config.toml tries to load - explicitly include the vendored xlink-src/ in the xlink-sys package: it is gitignored, so cargo silently dropped the C sources from the published .crate archive, making it unbuildable Signed-off-by: Filip Jeretina --- .github/workflows/rust-bindings.yml | 3 +++ rust/xlink-sys/Cargo.toml | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/rust-bindings.yml b/.github/workflows/rust-bindings.yml index 4635cc8..53e9e2a 100644 --- a/.github/workflows/rust-bindings.yml +++ b/.github/workflows/rust-bindings.yml @@ -44,6 +44,9 @@ jobs: run: working-directory: rust env: + # Override the credential providers from rust/.cargo/config.toml: + # cargo:libsecret needs libsecret-1.so.0 which CI runners don't have. + CARGO_REGISTRY_GLOBAL_CREDENTIAL_PROVIDERS: cargo:token CARGO_REGISTRIES_LUXONIS_TOKEN: ${{ secrets.CARGO_REGISTRIES_LUXONIS_TOKEN }} steps: - uses: actions/checkout@v4 diff --git a/rust/xlink-sys/Cargo.toml b/rust/xlink-sys/Cargo.toml index f20382b..7751bb6 100644 --- a/rust/xlink-sys/Cargo.toml +++ b/rust/xlink-sys/Cargo.toml @@ -7,6 +7,10 @@ edition.workspace = true license.workspace = true repository.workspace = true publish.workspace = true +# Explicit include list: `xlink-src/` (the C/C++ sources vendored by CI before +# publishing) is gitignored, and cargo would otherwise exclude it from the +# published .crate archive. +include = ["/src", "/build.rs", "/xlink-src"] [features] default = []