From 44fb602486f0384b02c7c0e41ba6190a1d3c1696 Mon Sep 17 00:00:00 2001 From: Shayne Hartford Date: Sun, 2 Aug 2026 11:38:22 -0400 Subject: [PATCH] 2.0.0 - Work in progress --- .github/workflows/release.yml | 18 +- Cargo.lock | 196 ++++++++- Cargo.toml | 19 +- build.rs | 36 ++ cbindgen.toml | 9 + include/lighthouse.h | 249 +++++++++++ src/ffi.rs | 758 ++++++++++++++++++++++++++++++++++ src/lib.rs | 49 +-- 8 files changed, 1299 insertions(+), 35 deletions(-) create mode 100644 build.rs create mode 100644 cbindgen.toml create mode 100644 include/lighthouse.h create mode 100644 src/ffi.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6da03b6..79da8aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,6 @@ on: release: - types: [created] + types: [ created ] jobs: release: @@ -11,20 +11,28 @@ jobs: fail-fast: false matrix: include: + - name: Linux-aarch64 + target: aarch64-unknown-linux-gnu + runner: ubuntu-latest + - name: Linux-x86_64 target: x86_64-unknown-linux-gnu runner: ubuntu-latest - - name: macOS-Apple + - name: macOS-Apple-aarch64 target: aarch64-apple-darwin runner: macos-latest - - name: macOS-Intel + - name: macOS-Intel-x86_64 target: x86_64-apple-darwin runner: macos-latest - - name: Windows - target: i686-pc-windows-msvc + - name: Windows-aarch64 + target: aarch64-pc-windows-msvc + runner: windows-latest + + - name: Windows-x86_64 + target: x86-64-pc-windows-msvc runner: windows-latest name: ${{ matrix.name }} diff --git a/Cargo.lock b/Cargo.lock index 312e3eb..0680427 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,25 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cbindgen" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" +dependencies = [ + "clap", + "heck", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.119", + "tempfile", + "toml", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -240,7 +259,7 @@ checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", - "hashbrown", + "hashbrown 0.14.5", "lock_api", "once_cell", "parking_lot_core", @@ -276,6 +295,28 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "futures" version = "0.3.33" @@ -364,18 +405,45 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -391,6 +459,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.19.0" @@ -467,9 +541,10 @@ dependencies = [ [[package]] name = "lighthouse" -version = "1.5.0" +version = "2.0.0" dependencies = [ "btleplug", + "cbindgen", "clap", "clap-verbosity-flag", "thiserror 2.0.19", @@ -480,6 +555,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -621,6 +702,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -630,6 +717,19 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -703,6 +803,28 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -768,6 +890,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -867,6 +1002,45 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tracing" version = "0.1.44" @@ -1142,8 +1316,26 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "xml" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "636f85e5ca6488e96401b61eb7de54f4e44755c988af0f52cf90230c312a1a89" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 6b1dd79..c3beac4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "lighthouse" description = "Virtual reality basestation power management in Rust" -version = "1.5.0" +version = "2.0.0" authors = ["Shayne Hartford "] edition = "2024" readme = "README.md" @@ -9,6 +9,7 @@ repository = "https://github.com/ShayBox/Lighthouse" license = "MIT" keywords = ["bluetooth", "valve", "htc", "basestation", "lighthouse"] categories = ["asynchronous", "command-line-utilities", "hardware-support"] +build = "build.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -16,21 +17,31 @@ categories = ["asynchronous", "command-line-utilities", "hardware-support"] name = "lighthouse" required-features = ["cli"] +[lib] +name = "lighthouse" +path = "src/lib.rs" +crate-type = ["rlib", "cdylib"] + [features] -cli = ["clap", "clap-verbosity-flag", "tracing", "tracing-log", "tracing-subscriber"] -tracing = ["dep:tracing"] +default = ["ffi"] +cli = ["clap", "clap-verbosity-flag", "log", "tracing-log", "tracing-subscriber"] +ffi = [] +log = ["dep:tracing"] [dependencies] btleplug = { version = "0.12", features = ["serde"] } clap = { version = "4", optional = true } clap-verbosity-flag = { version = "3", optional = true } thiserror = "2" -tokio = { version = "1", features = ["macros"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tracing = { version = "0.1", optional = true } tracing-log = { version = "0.2", optional = true } tracing-subscriber = { version = "0.3", optional = true } uuid = "1" +[build-dependencies] +cbindgen = "0.29" + # https://github.com/johnthagen/min-sized-rust [profile.release] strip = true # Automatically strip symbols from the binary. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..ae1b6a0 --- /dev/null +++ b/build.rs @@ -0,0 +1,36 @@ +use std::path::Path; + +use cbindgen::Config; + +fn main() { + println!("cargo:rerun-if-changed=src/lib.rs"); + println!("cargo:rerun-if-changed=src/ffi.rs"); + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=cbindgen.toml"); + + if std::env::var("CARGO_FEATURE_FFI").is_ok() { + generate_header(); + } +} + +fn generate_header() { + let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let config = Config::from_root_or_default(&crate_dir); + let header = match cbindgen::Builder::new() + .with_config(config) + .with_crate(&crate_dir) + .generate() + { + Ok(h) => h, + Err(e) => { + println!("cargo:warning=cbindgen generation failed: {e}"); + return; + } + }; + + let include_path = Path::new(&crate_dir).join("include").join("lighthouse.h"); + if let Some(parent) = include_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + header.write_to_file(&include_path); +} diff --git a/cbindgen.toml b/cbindgen.toml new file mode 100644 index 0000000..8de07e5 --- /dev/null +++ b/cbindgen.toml @@ -0,0 +1,9 @@ +language = "C" +style = "type" +include_guard = "LIGHTHOUSE_H" + +[export] +exclude = ["DiscoveredPeripheral", "Error", "State", "BaseStationVersion"] + +[parse] +parse_deps = false \ No newline at end of file diff --git a/include/lighthouse.h b/include/lighthouse.h new file mode 100644 index 0000000..c323aea --- /dev/null +++ b/include/lighthouse.h @@ -0,0 +1,249 @@ +#ifndef LIGHTHOUSE_H +#define LIGHTHOUSE_H + +#include +#include +#include +#include + +typedef enum { + Success = 0, + /** + * Failed to initialize the runtime + */ + InitFailed = -1, + /** + * No Bluetooth adapters found + */ + NoAdapters = -2, + /** + * Invalid handle + */ + InvalidHandle = -3, + /** + * Scan failed or timed out + */ + ScanFailed = -4, + /** + * Write to device failed + */ + WriteFailed = -5, + /** + * Invalid state enum value + */ + InvalidState = -6, + /** + * Memory allocation failure (`CString`, etc.) + */ + AllocFailed = -7, + /** + * Invalid null pointer argument + */ + NullPointer = -8, + /** + * General/other error + */ + Unknown = -99, +} LighthouseError; + +typedef enum { + Off = 0, + On = 1, + Standby = 2, +} LighthouseState; + +typedef uint64_t Handle; + +/** + * Initializes the Lighthouse FFI library. + * + * Creates the internal tokio runtime if not already created. + * + * # Returns + * `LighthouseError::Success` on success, or an error code. + */ +LighthouseError lighthouse_init(void); + +/** + * Discovers available Bluetooth adapters. + * + * # Arguments + * * `handles` - Pre-allocated array of handles to fill. Must be at least `*count` elements. + * * `count` - On input, the capacity of the handles array. On output, the number of adapters found. + * + * # Returns + * Error code. On success, `handles[0..*count]` contains valid adapter handles. + * + * # Safety + * `handles` must be a valid pointer to an array of at least `*count` `Handle` elements. + * `count` must be a valid pointer to a `u32`. + */ +LighthouseError lighthouse_discover_adapters(Handle *handles, uint32_t *count); + +/** + * Scans for Bluetooth peripherals on the given adapter. + * + * # Arguments + * * `adapter_handle` - Handle from `lighthouse_discover_adapters` + * * `timeout_sec` - Scan timeout in seconds + * * `bsids` - Optional array of BSID strings to filter (can be NULL for no filter) + * * `bsid_count` - Number of BSID strings + * * `handles` - Pre-allocated array for peripheral handles + * * `count` - On input, capacity of handles array. On output, number of peripherals found. + * + * # Returns + * Error code. + * + * # Safety + * `handles` must be a valid pointer to an array of at least `*count` `Handle` elements. + * `count` must be a valid pointer to a `u32`. + * `bsids` if not null must point to an array of `bsid_count` null-terminated strings. + * + * # Panics + * Panics if the arena lock is poisoned during the scan write phase. + */ +LighthouseError lighthouse_scan(Handle adapter_handle, + uint32_t timeout_sec, + const int8_t *const *bsids, + uint32_t bsid_count, + Handle *handles, + uint32_t *count); + +/** + * Sets the power state of a base station peripheral. + * + * # Arguments + * * `adapter_handle` - Handle to the adapter + * * `peripheral_handle` - Handle to the peripheral + * * `state` - Desired power state + * * `bsid` - Optional BSID string (required for V1 devices, ignored for V2). Can be NULL. + * * `retries` - Number of write attempts (minimum 1) + * * `retry_delay_sec` - Delay between retry attempts in seconds + * + * # Returns + * Error code. + * + * # Safety + * `bsid` if not null must be a valid null-terminated UTF-8 string. + */ +LighthouseError lighthouse_set_state(Handle adapter_handle, + Handle peripheral_handle, + LighthouseState state, + const int8_t *bsid, + uint32_t retries, + uint32_t retry_delay_sec); + +/** + * Gets human-readable adapter info. + * + * # Arguments + * * `adapter_handle` - Handle to the adapter + * * `info_out` - Output pointer. Caller must free with `lighthouse_free_string`. + * + * # Returns + * Error code. + * + * # Safety + * `info_out` must be a valid pointer to a `*const i8`. + */ +LighthouseError lighthouse_adapter_info(Handle adapter_handle, const int8_t **info_out); + +/** + * Gets the name of a peripheral. + * + * # Arguments + * * `peripheral_handle` - Handle to the peripheral + * * `name_out` - Output pointer. Caller must free with `lighthouse_free_string`. + * + * # Returns + * Error code. + * + * # Safety + * `name_out` must be a valid pointer to a `*const i8`. + */ +LighthouseError lighthouse_peripheral_name(Handle peripheral_handle, const int8_t **name_out); + +/** + * Gets the ID string of a peripheral. + * + * # Arguments + * * `peripheral_handle` - Handle to the peripheral + * * `id_out` - Output pointer. Caller must free with `lighthouse_free_string`. + * + * # Returns + * Error code. + * + * # Safety + * `id_out` must be a valid pointer to a `*const i8`. + */ +LighthouseError lighthouse_peripheral_id(Handle peripheral_handle, const int8_t **id_out); + +/** + * Detects the base station version from a device name. + * + * # Arguments + * * `name` - Device name string + * + * # Returns + * `LighthouseBaseStationVersion` value, or -1 if unknown. + * + * # Safety + * `name` must be a valid null-terminated UTF-8 string. + */ +int32_t lighthouse_detect_version(const int8_t *name); + +/** + * Releases a handle, freeing the associated resources. + * + * # Arguments + * * `handle` - The handle to release + * + * # Returns + * Error code. Returns `LighthouseError::Success` if the handle was valid and released. + */ +LighthouseError lighthouse_release_handle(Handle handle); + +/** + * Frees a string that was allocated by the library. + * + * # Arguments + * * `ptr` - Pointer returned from an FFI function (e.g., `lighthouse_adapter_info`). + * + * # Safety + * The pointer must have been returned by an FFI function that documents + * that the caller is responsible for freeing it. Must not be called twice + * on the same pointer. + */ +void lighthouse_free_string(int8_t *ptr); + +/** + * Gets the last error message. + * + * # Returns + * A null-terminated string. The pointer is valid until the next FFI call + * on the same thread. Do not free. + */ +const int8_t *lighthouse_last_error(void); + +/** + * Checks if all requested BSID targets have been found in a list of peripherals. + * + * # Arguments + * * `peripheral_handles` - Array of peripheral handles + * * `peripheral_count` - Number of peripherals + * * `bsids` - Array of BSID strings + * * `bsid_count` - Number of BSIDs + * + * # Returns + * 1 if all targets found, 0 otherwise, -1 on error. + * + * # Safety + * `peripheral_handles` must be a valid array of `peripheral_count` elements. + * `bsids` if not null must point to an array of `bsid_count` null-terminated strings. + */ +int32_t lighthouse_all_targets_found(const Handle *peripheral_handles, + uint32_t peripheral_count, + const int8_t *const *bsids, + uint32_t bsid_count); + +#endif /* LIGHTHOUSE_H */ diff --git a/src/ffi.rs b/src/ffi.rs new file mode 100644 index 0000000..6e01723 --- /dev/null +++ b/src/ffi.rs @@ -0,0 +1,758 @@ +use std::{ + cell::RefCell, + collections::HashMap, + ffi::{CStr, CString}, + sync::{OnceLock, RwLock}, + time::Duration, +}; + +use btleplug::platform::Adapter; +use tokio::runtime::Runtime; + +use crate::{ + BaseStationVersion, + DiscoveredPeripheral, + Error, + State, + adapter_info, + adapters, + all_requested_targets_found, + process_peripheral, + scan_peripherals, +}; + +static RUNTIME: OnceLock = OnceLock::new(); + +fn runtime() -> &'static Runtime { + RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("Failed to create tokio runtime") + }) +} + +#[repr(C)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum LighthouseError { + Success = 0, + /// Failed to initialize the runtime + InitFailed = -1, + /// No Bluetooth adapters found + NoAdapters = -2, + /// Invalid handle + InvalidHandle = -3, + /// Scan failed or timed out + ScanFailed = -4, + /// Write to device failed + WriteFailed = -5, + /// Invalid state enum value + InvalidState = -6, + /// Memory allocation failure (`CString`, etc.) + AllocFailed = -7, + /// Invalid null pointer argument + NullPointer = -8, + /// General/other error + Unknown = -99, +} + +impl From for LighthouseError { + fn from(_: Error) -> Self { + Self::Unknown + } +} + +thread_local! { + /// Thread-local last error message. + static LAST_ERROR: RefCell = const { RefCell::new(String::new()) }; +} + +fn set_last_error(msg: &str) { + LAST_ERROR.with(|e| { + let mut err = e.borrow_mut(); + err.clear(); + err.push_str(msg); + }); +} + +#[repr(C)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum LighthouseState { + Off = 0, + On = 1, + Standby = 2, +} + +impl From for State { + fn from(state: LighthouseState) -> Self { + match state { + LighthouseState::Off => Self::Off, + LighthouseState::On => Self::On, + LighthouseState::Standby => Self::Standby, + } + } +} + +pub type Handle = u64; + +struct Arena { + adapters: HashMap, + peripherals: HashMap, + next_id: Handle, +} + +impl Arena { + #[must_use] + fn new() -> Self { + Self { + adapters: HashMap::new(), + peripherals: HashMap::new(), + next_id: 1, + } + } + + fn alloc_adapter(&mut self, adapter: Adapter) -> Handle { + let id = self.next_id; + self.next_id += 1; + self.adapters.insert(id, adapter); + id + } + + fn alloc_peripheral(&mut self, peripheral: DiscoveredPeripheral) -> Handle { + let id = self.next_id; + self.next_id += 1; + self.peripherals.insert(id, peripheral); + id + } + + #[allow(clippy::missing_const_for_fn)] + fn get_adapter(&self, handle: Handle) -> Option<&Adapter> { + self.adapters.get(&handle) + } + + fn get_peripheral(&self, handle: Handle) -> Option<&DiscoveredPeripheral> { + self.peripherals.get(&handle) + } + + fn release_adapter(&mut self, handle: Handle) -> bool { + self.adapters.remove(&handle).is_some() + } + + fn release_peripheral(&mut self, handle: Handle) -> bool { + self.peripherals.remove(&handle).is_some() + } +} + +static ARENA: OnceLock> = OnceLock::new(); + +fn arena() -> &'static RwLock { + ARENA.get_or_init(|| RwLock::new(Arena::new())) +} + +/// Initializes the Lighthouse FFI library. +/// +/// Creates the internal tokio runtime if not already created. +/// +/// # Returns +/// `LighthouseError::Success` on success, or an error code. +#[unsafe(no_mangle)] +pub extern "C" fn lighthouse_init() -> LighthouseError { + runtime(); + set_last_error("Initialized successfully"); + LighthouseError::Success +} + +/// Discovers available Bluetooth adapters. +/// +/// # Arguments +/// * `handles` - Pre-allocated array of handles to fill. Must be at least `*count` elements. +/// * `count` - On input, the capacity of the handles array. On output, the number of adapters found. +/// +/// # Returns +/// Error code. On success, `handles[0..*count]` contains valid adapter handles. +/// +/// # Safety +/// `handles` must be a valid pointer to an array of at least `*count` `Handle` elements. +/// `count` must be a valid pointer to a `u32`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lighthouse_discover_adapters( + handles: *mut Handle, + count: *mut u32, +) -> LighthouseError { + if handles.is_null() || count.is_null() { + return LighthouseError::NullPointer; + } + + let capacity = unsafe { *count }; + let result = runtime().block_on(async { + match adapters().await { + Ok(adapters) => Ok(adapters), + Err(e) => { + set_last_error(&format!("Failed to discover adapters: {e}")); + Err(LighthouseError::NoAdapters) + } + } + }); + + let adapters = match result { + Ok(a) => a, + Err(e) => return e, + }; + + let Ok(mut arena) = arena().write() else { + set_last_error("Arena lock poisoned"); + return LighthouseError::Unknown; + }; + + let num_to_copy = adapters.len().min(capacity as usize); + let mut handles_written: u32 = 0; + + for adapter in adapters.iter().take(num_to_copy) { + let handle = arena.alloc_adapter(adapter.clone()); + unsafe { + *handles.add(handles_written as usize) = handle; + } + handles_written += 1; + } + + unsafe { + *count = handles_written; + } + drop(arena); + set_last_error(&format!("Discovered {handles_written} adapter(s)")); + LighthouseError::Success +} + +/// Scans for Bluetooth peripherals on the given adapter. +/// +/// # Arguments +/// * `adapter_handle` - Handle from `lighthouse_discover_adapters` +/// * `timeout_sec` - Scan timeout in seconds +/// * `bsids` - Optional array of BSID strings to filter (can be NULL for no filter) +/// * `bsid_count` - Number of BSID strings +/// * `handles` - Pre-allocated array for peripheral handles +/// * `count` - On input, capacity of handles array. On output, number of peripherals found. +/// +/// # Returns +/// Error code. +/// +/// # Safety +/// `handles` must be a valid pointer to an array of at least `*count` `Handle` elements. +/// `count` must be a valid pointer to a `u32`. +/// `bsids` if not null must point to an array of `bsid_count` null-terminated strings. +/// +/// # Panics +/// Panics if the arena lock is poisoned during the scan write phase. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lighthouse_scan( + adapter_handle: Handle, + timeout_sec: u32, + bsids: *const *const i8, + bsid_count: u32, + handles: *mut Handle, + count: *mut u32, +) -> LighthouseError { + if handles.is_null() || count.is_null() { + return LighthouseError::NullPointer; + } + + let Some(_bsid_list) = parse_string_array(bsids, bsid_count) else { + return LighthouseError::NullPointer; + }; + + // Clone adapter out of arena before block_on to avoid holding lock during async ops + let adapter = { + let Ok(arena) = arena().read() else { + set_last_error("Arena lock poisoned"); + return LighthouseError::Unknown; + }; + if let Some(a) = arena.get_adapter(adapter_handle) { + a.clone() + } else { + set_last_error("Invalid adapter handle"); + return LighthouseError::InvalidHandle; + } + }; + + let capacity = unsafe { *count }; + let timeout = Duration::from_secs(u64::from(timeout_sec)); + + let result = runtime().block_on(async { + let peripherals = match scan_peripherals(&adapter, timeout).await { + Ok(p) => p, + Err(e) => { + set_last_error(&format!("Scan failed: {e}")); + return Err(LighthouseError::ScanFailed); + } + }; + + let mut arena_write = arena().write().unwrap(); + let num_to_copy = peripherals.len().min(capacity as usize); + let mut handles_written: u32 = 0; + + for peripheral in peripherals.iter().take(num_to_copy) { + let handle = arena_write.alloc_peripheral(peripheral.clone()); + unsafe { + *handles.add(handles_written as usize) = handle; + } + handles_written += 1; + } + + unsafe { + *count = handles_written; + } + drop(arena_write); + set_last_error(&format!("Found {handles_written} peripheral(s)")); + Ok(()) + }); + + match result { + Ok(()) => LighthouseError::Success, + Err(e) => e, + } +} + +/// Sets the power state of a base station peripheral. +/// +/// # Arguments +/// * `adapter_handle` - Handle to the adapter +/// * `peripheral_handle` - Handle to the peripheral +/// * `state` - Desired power state +/// * `bsid` - Optional BSID string (required for V1 devices, ignored for V2). Can be NULL. +/// * `retries` - Number of write attempts (minimum 1) +/// * `retry_delay_sec` - Delay between retry attempts in seconds +/// +/// # Returns +/// Error code. +/// +/// # Safety +/// `bsid` if not null must be a valid null-terminated UTF-8 string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lighthouse_set_state( + adapter_handle: Handle, + peripheral_handle: Handle, + state: LighthouseState, + bsid: *const i8, + retries: u32, + retry_delay_sec: u32, +) -> LighthouseError { + let bsid_string = if bsid.is_null() { + None + } else if let Ok(s) = unsafe { CStr::from_ptr(bsid) }.to_str() { + Some(s.to_owned()) + } else { + set_last_error("Invalid BSID string (not valid UTF-8)"); + return LighthouseError::InvalidState; + }; + + let state_rust: State = state.into(); + let retry_delay = Duration::from_secs(u64::from(retry_delay_sec)); + + // Clone the data we need out of the arena to avoid holding the lock during async ops + let (adapter, peripheral) = { + let Ok(arena) = arena().read() else { + set_last_error("Arena lock poisoned"); + return LighthouseError::Unknown; + }; + let Some(adapter) = arena.get_adapter(adapter_handle) else { + set_last_error("Invalid adapter handle"); + return LighthouseError::InvalidHandle; + }; + let Some(peripheral) = arena.get_peripheral(peripheral_handle) else { + set_last_error("Invalid peripheral handle"); + return LighthouseError::InvalidHandle; + }; + (adapter.clone(), peripheral.clone()) + }; + + let result = runtime().block_on(async { + let bsids: Vec = bsid_string + .as_ref() + .map_or_else(Vec::new, |b| vec![b.clone()]); + + match process_peripheral( + &adapter, + &peripheral, + &state_rust, + &bsids, + retries.max(1), + retry_delay, + ) + .await + { + Ok(Some(desc)) => { + set_last_error(&format!("Success: {desc}")); + Ok(LighthouseError::Success) + } + Ok(None) => { + set_last_error( + "Peripheral did not match any target (unknown version or BSID mismatch)", + ); + Ok(LighthouseError::Success) + } + Err(e) => { + set_last_error(&format!("Write failed: {e}")); + Err(LighthouseError::WriteFailed) + } + } + }); + + result.unwrap_or(LighthouseError::Unknown) +} + +/// Gets human-readable adapter info. +/// +/// # Arguments +/// * `adapter_handle` - Handle to the adapter +/// * `info_out` - Output pointer. Caller must free with `lighthouse_free_string`. +/// +/// # Returns +/// Error code. +/// +/// # Safety +/// `info_out` must be a valid pointer to a `*const i8`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lighthouse_adapter_info( + adapter_handle: Handle, + info_out: *mut *const i8, +) -> LighthouseError { + if info_out.is_null() { + return LighthouseError::NullPointer; + } + + // Clone adapter out of arena + let adapter = { + let Ok(arena) = arena().read() else { + set_last_error("Arena lock poisoned"); + return LighthouseError::Unknown; + }; + if let Some(a) = arena.get_adapter(adapter_handle) { + a.clone() + } else { + set_last_error("Invalid adapter handle"); + return LighthouseError::InvalidHandle; + } + }; + + let result = runtime().block_on(async { + match adapter_info(&adapter).await { + Ok(info) => Ok(info), + Err(e) => { + set_last_error(&format!("Failed to get adapter info: {e}")); + Err(LighthouseError::Unknown) + } + } + }); + + match result { + Ok(info_str) => CString::new(info_str).map_or_else( + |_| { + set_last_error("Failed to create CString for adapter info"); + LighthouseError::AllocFailed + }, + |c_string| { + unsafe { + *info_out = c_string.into_raw(); + } + LighthouseError::Success + }, + ), + Err(e) => e, + } +} + +/// Gets the name of a peripheral. +/// +/// # Arguments +/// * `peripheral_handle` - Handle to the peripheral +/// * `name_out` - Output pointer. Caller must free with `lighthouse_free_string`. +/// +/// # Returns +/// Error code. +/// +/// # Safety +/// `name_out` must be a valid pointer to a `*const i8`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lighthouse_peripheral_name( + peripheral_handle: Handle, + name_out: *mut *const i8, +) -> LighthouseError { + if name_out.is_null() { + return LighthouseError::NullPointer; + } + + let name = { + let Ok(arena) = arena().read() else { + set_last_error("Arena lock poisoned"); + return LighthouseError::Unknown; + }; + if let Some(p) = arena.get_peripheral(peripheral_handle) { + p.name.clone() + } else { + set_last_error("Invalid peripheral handle"); + return LighthouseError::InvalidHandle; + } + }; + + CString::new(name).map_or_else( + |_| { + set_last_error("Failed to create CString for peripheral name"); + LighthouseError::AllocFailed + }, + |c_string| { + unsafe { + *name_out = c_string.into_raw(); + } + LighthouseError::Success + }, + ) +} + +/// Gets the ID string of a peripheral. +/// +/// # Arguments +/// * `peripheral_handle` - Handle to the peripheral +/// * `id_out` - Output pointer. Caller must free with `lighthouse_free_string`. +/// +/// # Returns +/// Error code. +/// +/// # Safety +/// `id_out` must be a valid pointer to a `*const i8`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lighthouse_peripheral_id( + peripheral_handle: Handle, + id_out: *mut *const i8, +) -> LighthouseError { + if id_out.is_null() { + return LighthouseError::NullPointer; + } + + let id_str = { + let Ok(arena) = arena().read() else { + set_last_error("Arena lock poisoned"); + return LighthouseError::Unknown; + }; + if let Some(p) = arena.get_peripheral(peripheral_handle) { + p.id.to_string() + } else { + set_last_error("Invalid peripheral handle"); + return LighthouseError::InvalidHandle; + } + }; + + CString::new(id_str).map_or_else( + |_| { + set_last_error("Failed to create CString for peripheral id"); + LighthouseError::AllocFailed + }, + |c_string| { + unsafe { + *id_out = c_string.into_raw(); + } + LighthouseError::Success + }, + ) +} + +/// Detects the base station version from a device name. +/// +/// # Arguments +/// * `name` - Device name string +/// +/// # Returns +/// `LighthouseBaseStationVersion` value, or -1 if unknown. +/// +/// # Safety +/// `name` must be a valid null-terminated UTF-8 string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lighthouse_detect_version(name: *const i8) -> i32 { + if name.is_null() { + return -1; + } + let c_str = unsafe { CStr::from_ptr(name) }; + let Ok(name_str) = c_str.to_str() else { + return -1; + }; + + match BaseStationVersion::detect(name_str) { + Some(BaseStationVersion::V1) => 0, + Some(BaseStationVersion::V2) => 1, + None => -1, + } +} + +/// Releases a handle, freeing the associated resources. +/// +/// # Arguments +/// * `handle` - The handle to release +/// +/// # Returns +/// Error code. Returns `LighthouseError::Success` if the handle was valid and released. +#[unsafe(no_mangle)] +pub extern "C" fn lighthouse_release_handle(handle: Handle) -> LighthouseError { + let Ok(mut arena) = arena().write() else { + set_last_error("Arena lock poisoned"); + return LighthouseError::Unknown; + }; + + if arena.release_adapter(handle) { + set_last_error("Released adapter handle"); + return LighthouseError::Success; + } + if arena.release_peripheral(handle) { + set_last_error("Released peripheral handle"); + return LighthouseError::Success; + } + + drop(arena); + set_last_error("Handle not found (already released or invalid)"); + LighthouseError::InvalidHandle +} + +/// Frees a string that was allocated by the library. +/// +/// # Arguments +/// * `ptr` - Pointer returned from an FFI function (e.g., `lighthouse_adapter_info`). +/// +/// # Safety +/// The pointer must have been returned by an FFI function that documents +/// that the caller is responsible for freeing it. Must not be called twice +/// on the same pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lighthouse_free_string(ptr: *mut i8) { + if !ptr.is_null() { + let _ = unsafe { CString::from_raw(ptr) }; + } +} + +/// Gets the last error message. +/// +/// # Returns +/// A null-terminated string. The pointer is valid until the next FFI call +/// on the same thread. Do not free. +#[unsafe(no_mangle)] +pub extern "C" fn lighthouse_last_error() -> *const i8 { + LAST_ERROR.with(|e| e.borrow().as_ptr().cast::()) +} + +/// Checks if all requested BSID targets have been found in a list of peripherals. +/// +/// # Arguments +/// * `peripheral_handles` - Array of peripheral handles +/// * `peripheral_count` - Number of peripherals +/// * `bsids` - Array of BSID strings +/// * `bsid_count` - Number of BSIDs +/// +/// # Returns +/// 1 if all targets found, 0 otherwise, -1 on error. +/// +/// # Safety +/// `peripheral_handles` must be a valid array of `peripheral_count` elements. +/// `bsids` if not null must point to an array of `bsid_count` null-terminated strings. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lighthouse_all_targets_found( + peripheral_handles: *const Handle, + peripheral_count: u32, + bsids: *const *const i8, + bsid_count: u32, +) -> i32 { + let Some(bsid_list) = parse_string_array(bsids, bsid_count) else { + return -1; + }; + + if peripheral_handles.is_null() || peripheral_count == 0 { + return i32::from(bsid_list.is_empty()); + } + + let Ok(arena) = arena().read() else { + return -1; + }; + + let mut discovered = Vec::new(); + + for i in 0..peripheral_count as usize { + let handle = unsafe { *peripheral_handles.add(i) }; + if let Some(p) = arena.get_peripheral(handle) { + discovered.push(p.clone()); + } + } + + i32::from(all_requested_targets_found(&discovered, &bsid_list)) +} + +/// Parse a null-terminated array of `*const c_char` into `Vec`. +/// +/// # Safety +/// `ptr` must point to an array of `count` null-terminated strings, or be NULL. +fn parse_string_array(ptr: *const *const i8, count: u32) -> Option> { + if ptr.is_null() || count == 0 { + return Some(Vec::new()); + } + unsafe { + let slice = std::slice::from_raw_parts(ptr, count as usize); + let mut result = Vec::with_capacity(slice.len()); + for s in slice { + if s.is_null() { + return None; + } + let c_str = CStr::from_ptr(*s); + let string = c_str.to_string_lossy().into_owned(); + result.push(string); + } + Some(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_state_conversion() { + assert_eq!(State::from(LighthouseState::Off), State::Off); + assert_eq!(State::from(LighthouseState::On), State::On); + assert_eq!(State::from(LighthouseState::Standby), State::Standby); + } + + #[test] + fn test_ffi_init_returns_success() { + let _ = lighthouse_init(); + } + + #[test] + fn test_scan_null_pointer_returns_error() { + let mut handles: Vec = vec![0; 16]; + let mut count: u32 = 16; + unsafe { + let err = lighthouse_scan( + 99999, + 1, + [std::ptr::null()].as_ptr(), + 1, + handles.as_mut_ptr(), + &raw mut count, + ); + assert_eq!(err, LighthouseError::NullPointer); + } + } + + #[test] + fn test_scan_invalid_adapter_handle() { + let mut handles: Vec = vec![0; 16]; + let mut count: u32 = 16; + let bsid_str = CString::new("test").unwrap(); + let bsids = [bsid_str.as_ptr()]; + unsafe { + let err = lighthouse_scan( + 99999, + 1, + bsids.as_ptr(), + 1, + handles.as_mut_ptr(), + &raw mut count, + ); + assert_eq!(err, LighthouseError::InvalidHandle); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 09d3446..b71eab9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +pub mod ffi; + use std::{fmt, str::FromStr, sync::LazyLock, time::Duration}; use btleplug::{ @@ -55,7 +57,12 @@ impl BaseStationVersion { /// For V2, returns `Some(())` as an empty string marker if the peripheral matches, /// or `None` if it does not match. #[must_use] - pub fn matches_bsid(&self, name: &str, peripheral_id: &str, bsids: &[String]) -> Option { + pub fn matches_bsid( + &self, + name: &str, + peripheral_id: &str, + bsids: &[String], + ) -> Option { match self { Self::V1 => matches_v1_bsid(name, bsids).map(String::from), Self::V2 => { @@ -279,33 +286,25 @@ pub async fn process_peripheral( }; // V2 returns empty string (no BSID needed), V1 returns the actual BSID - let bsid_for_cmd = if bsid.is_empty() { None } else { Some(bsid.as_str()) }; + let bsid_for_cmd = if bsid.is_empty() { + None + } else { + Some(bsid.as_str()) + }; let cmd = version.command(state, bsid_for_cmd)?; let uuid = *version.uuid(); - write_with_retries( - adapter, - peripheral, - &cmd, - uuid, - retries.max(1), - retry_delay, - ) - .await?; + write_with_retries(adapter, peripheral, &cmd, uuid, retries.max(1), retry_delay).await?; Ok(Some(format!( "{} [{}]: {state}", - peripheral.name, - peripheral_id_str + peripheral.name, peripheral_id_str ))) } /// Checks if all requested targets have been found in the discovered peripherals. #[must_use] -pub fn all_requested_targets_found( - peripherals: &[DiscoveredPeripheral], - bsids: &[String], -) -> bool { +pub fn all_requested_targets_found(peripherals: &[DiscoveredPeripheral], bsids: &[String]) -> bool { if bsids.is_empty() { return false; } @@ -317,11 +316,13 @@ pub fn all_requested_targets_found( return false; }; - version.matches_bsid( - &peripheral.name, - &peripheral_id_str, - std::slice::from_ref(bsid), - ).is_some() + version + .matches_bsid( + &peripheral.name, + &peripheral_id_str, + std::slice::from_ref(bsid), + ) + .is_some() }) }) } @@ -352,7 +353,7 @@ pub async fn write_with_retries( Err(error) if attempt == retries => return Err(error), #[allow(unused_variables)] Err(error) => { - #[cfg(feature = "tracing")] + #[cfg(feature = "log")] tracing::warn!( attempt, retries, @@ -441,7 +442,7 @@ pub async fn scan_peripherals_until( #[allow(unused_variables)] if let Err(error) = adapter.stop_scan().await { - #[cfg(feature = "tracing")] + #[cfg(feature = "log")] tracing::debug!(%error, "Failed to stop scan"); }