diff --git a/Cargo.lock b/Cargo.lock index f3b43a18..a2b2082f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -809,6 +809,12 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "dissimilar" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8975ffdaa0ef3661bfe02dbdcc06c9f829dfafe6a3c474de366a8d5e44276921" + [[package]] name = "either" version = "1.11.0" @@ -900,6 +906,16 @@ dependencies = [ "log", ] +[[package]] +name = "expect-test" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63af43ff4431e848fb47472a920f14fa71c24de13255a5692e93d4e90302acb0" +dependencies = [ + "dissimilar", + "once_cell", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1595,7 +1611,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -3049,6 +3065,21 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vdo" +version = "0.0.0" +dependencies = [ + "anyhow", + "env_logger", + "expect-test", + "glib", + "glib-sys", + "gobject-sys", + "log", + "thiserror 1.0.69", + "vdo-sys", +] + [[package]] name = "vdo-sys" version = "0.0.0" @@ -3065,7 +3096,7 @@ version = "0.0.0" dependencies = [ "acap-logging", "log", - "vdo-sys", + "vdo", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index be509b58..a36ea870 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ diqwest = "3.1.0" digest_auth = "0.3.1" dirs = "5.0.1" env_logger = { version = "0.11.3", default-features = false } +expect-test = "1.5.1" futures = "0.3.30" futures-lite = "2.6.0" futures-util = "0.3.30" @@ -64,6 +65,7 @@ licensekey = { path = "crates/licensekey" } licensekey-sys = { path = "crates/licensekey-sys" } mdb = { path = "crates/mdb" } mdb-sys = { path = "crates/mdb-sys" } +vdo = { path = "crates/vdo" } vdo-sys = { path = "crates/vdo-sys" } [workspace.package] diff --git a/Makefile b/Makefile index 1434b043..2828ae02 100644 --- a/Makefile +++ b/Makefile @@ -231,6 +231,7 @@ check_tests: --exclude bbox \ --exclude licensekey \ --exclude mdb \ + --exclude vdo \ --locked \ --workspace .PHONY: check_tests diff --git a/apps-aarch64.checksum b/apps-aarch64.checksum index 33a3d003..f597feba 100644 --- a/apps-aarch64.checksum +++ b/apps-aarch64.checksum @@ -15,4 +15,4 @@ d2365ffe408d5a77b326589d664d3647f4e99ac8 target-aarch64/acap/send_event_1_0_0_a abfdb79508c4395f124c9cbe622e695515a8054f target-aarch64/acap/subscribe_to_event_1_0_0_aarch64.eap a6e0ee05e0ea5b0e76308b6d91b06b61fb2d9029 target-aarch64/acap/using_a_build_script_0_0_0_aarch64.eap 61c0f96d5388ebb6a46a80f0fd2f57b86143a2f6 target-aarch64/acap/vapix_access_0_0_0_aarch64.eap -8f9dea1da7a39c1c37b99d6ab1a33d0abe0d643f target-aarch64/acap/vdoencodeclient_1_0_0_aarch64.eap +a69abf2f8bdb3201f75a0cdcb52b44121f171f53 target-aarch64/acap/vdoencodeclient_1_0_0_aarch64.eap diff --git a/apps-aarch64.filesize b/apps-aarch64.filesize index 0c20c747..ee6e8ef4 100644 --- a/apps-aarch64.filesize +++ b/apps-aarch64.filesize @@ -15,4 +15,4 @@ 3418 target-aarch64/acap/subscribe_to_event_1_0_0_aarch64.eap 899 target-aarch64/acap/using_a_build_script_0_0_0_aarch64.eap 11330 target-aarch64/acap/vapix_access_0_0_0_aarch64.eap -1407 target-aarch64/acap/vdoencodeclient_1_0_0_aarch64.eap +2837 target-aarch64/acap/vdoencodeclient_1_0_0_aarch64.eap diff --git a/apps/vdo_encode_client/Cargo.toml b/apps/vdo_encode_client/Cargo.toml index 03857640..da9a62cd 100644 --- a/apps/vdo_encode_client/Cargo.toml +++ b/apps/vdo_encode_client/Cargo.toml @@ -8,4 +8,4 @@ publish = false log = { workspace = true } acap-logging = { workspace = true } -vdo-sys = { workspace = true } +vdo = { workspace = true } diff --git a/apps/vdo_encode_client/src/main.rs b/apps/vdo_encode_client/src/main.rs index 25439cce..eefea219 100644 --- a/apps/vdo_encode_client/src/main.rs +++ b/apps/vdo_encode_client/src/main.rs @@ -1,20 +1,113 @@ -//! This application is a basic VDO type of application. +//! VDO Example Application //! -//! The application starts a VDO stream and illustrates how to continuously capture frames from the -//! VDO service, access the received buffer contents, as well as the frame metadata. +//! This application demonstrates the VDO (Video Capture) API by capturing +//! frames from the camera in various formats. //! -//! # Arguments -//! -//! - `format`: A string describing the video compression format. -//! Possible values are `h264` (default), `h265`, `jpeg`, `nv12`, and `y800`. -//! - `frames`: An integer specifying the number of captured frames. -//! - `output`: The output filename. -//! -use log::info; +//! It tests: +//! - Stream creation with different formats (YUV, JPEG, H.264) +//! - Frame capture and metadata access +//! - Proper resource cleanup + +// These format tests run on the device as an ACAP application rather than as +// unit tests because they require access to actual camera hardware via the VDO API. + +use log::{error, info}; +use vdo::{Error, Resolution, StreamBuilder, VdoFormat}; + +fn capture_format(name: &str, format: VdoFormat, num_frames: usize) -> Result<(), Error> { + info!("=== Testing {} format ===", name); + + let stream = StreamBuilder::new() + .channel(0) + .format(format) + .resolution(Resolution::Exact { + width: 640, + height: 480, + }) + .framerate(15) + .build()?; + + info!("{}: Stream created successfully", name); + + // Get stream info + if let Ok(stream_info) = stream.info() { + info!("{}: Stream info:", name); + stream_info.dump(); + } + + let running = stream.start()?; + info!("{}: Stream started", name); + + for i in 0..num_frames { + let buffer = running.next_buffer()?; + let size = buffer.size(); + let seq = buffer.sequence_number(); + let ts = buffer.timestamp(); + + info!( + "{}: Frame {}: {} bytes, seq={}, timestamp={}us", + name, i, size, seq, ts + ); + + // For JPEG, verify magic bytes + if format == VdoFormat::VDO_FORMAT_JPEG { + let data = buffer.as_slice()?; + if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 { + info!("{}: Frame {} has valid JPEG header", name, i); + } else { + error!("{}: Frame {} has INVALID JPEG header!", name, i); + } + } + } + + drop(running); + info!("{}: Stream stopped successfully", name); + info!(""); + + Ok(()) +} fn main() { acap_logging::init_logger(); - unsafe { assert!(!vdo_sys::vdo_map_new().is_null()) }; - info!("vdo map created"); - todo!("Implement the real example") + + info!("VDO Example Application starting..."); + info!("Testing VDO safe Rust bindings"); + info!(""); + + // Test YUV (most portable format) + match capture_format("YUV", VdoFormat::VDO_FORMAT_YUV, 5) { + Ok(()) => info!("YUV test: PASSED"), + Err(e) => error!("YUV test: FAILED - {}", e), + } + + // Test JPEG + match capture_format("JPEG", VdoFormat::VDO_FORMAT_JPEG, 5) { + Ok(()) => info!("JPEG test: PASSED"), + Err(e) => error!("JPEG test: FAILED - {}", e), + } + + // Test H.264 + match capture_format("H.264", VdoFormat::VDO_FORMAT_H264, 10) { + Ok(()) => info!("H.264 test: PASSED"), + Err(e) => error!("H.264 test: FAILED - {}", e), + } + + // Test H.265 (might not be supported on all platforms) + match capture_format("H.265", VdoFormat::VDO_FORMAT_H265, 5) { + Ok(()) => info!("H.265 test: PASSED"), + Err(e) => { + if let Error::Vdo(ref vdo_err) = e { + if vdo_err.code_name() == "VDO_ERROR_NOT_SUPPORTED" { + info!("H.265 test: SKIPPED (not supported on this platform)"); + } else { + error!("H.265 test: FAILED - {}", e); + } + } else { + error!("H.265 test: FAILED - {}", e); + } + } + } + + info!(""); + info!("VDO Example Application completed!"); } diff --git a/crates/vdo/Cargo.toml b/crates/vdo/Cargo.toml new file mode 100644 index 00000000..8cf99968 --- /dev/null +++ b/crates/vdo/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "vdo" +version = "0.0.0" +edition.workspace = true +license = "MIT" +description = "Safe Rust bindings for the VDO (Video Capture) API" + +[dependencies] +vdo-sys = { workspace = true } +log = { workspace = true } +glib = { workspace = true } +glib-sys = { workspace = true } +gobject-sys = { workspace = true } +thiserror = { workspace = true } + +[features] +device-tests = [] + +[dev-dependencies] +anyhow = { workspace = true } +env_logger = { workspace = true } +expect-test = { workspace = true } + +[[example]] +name = "basic" diff --git a/crates/vdo/examples/basic.rs b/crates/vdo/examples/basic.rs new file mode 100644 index 00000000..78ca5c41 --- /dev/null +++ b/crates/vdo/examples/basic.rs @@ -0,0 +1,45 @@ +//! Basic example of using VDO to capture video frames. +//! +//! This example creates a video stream, captures a few frames, and prints +//! information about each frame. + +use vdo::{Resolution, StreamBuilder, VdoFormat}; + +fn main() -> Result<(), Box> { + // Initialize logging (optional) + env_logger::init(); + + println!("Creating video stream..."); + + // Create a stream with YUV format (most portable across platforms) + let stream = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_YUV) + .resolution(Resolution::Exact { + width: 640, + height: 480, + }) + .framerate(15) + .build()?; + + println!("Starting stream..."); + let running = stream.start()?; + + println!("Capturing frames..."); + for i in 0..10 { + let buffer = running.next_buffer()?; + println!( + "Frame {}: {} bytes, seq={}, timestamp={}us", + i, + buffer.size(), + buffer.sequence_number(), + buffer.timestamp() + ); + } + + println!("Stopping stream..."); + drop(running); + + println!("Done!"); + Ok(()) +} diff --git a/crates/vdo/src/lib.rs b/crates/vdo/src/lib.rs new file mode 100644 index 00000000..199998b5 --- /dev/null +++ b/crates/vdo/src/lib.rs @@ -0,0 +1,1258 @@ +//! Safe Rust bindings for the [VDO (Video Capture) API](https://axiscommunications.github.io/acap-documentation/docs/api/src/api/vdostream/html/index.html). +//! +//! VDO provides access to video streams from Axis cameras, supporting various +//! video formats including H.264, H.265, JPEG, and raw YUV/RGB formats. +//! +//! # Platform Compatibility +//! +//! Video format support varies by hardware platform: +//! +//! | Format | Artpec-6 | Artpec-7 | Artpec-8 | Artpec-9 | Ambarella CV | +//! |--------|----------|----------|----------|----------|--------------| +//! | H.264 | Yes | Yes | Yes | Yes | Yes | +//! | H.265 | No | Yes | Yes | Yes | Yes | +//! | JPEG | Yes | Yes | Yes | Yes | Yes | +//! | YUV (NV12, Y800) | Yes | Yes | Yes | Yes | Yes | +//! | RGB | No | No | No | Yes | Yes | +//! | PLANAR_RGB | No | No | Yes | Yes | Yes | +//! | AV1 | No | No | No | Yes | No | +//! +//! For maximum portability, use `VdoFormat::VDO_FORMAT_YUV`. +//! +//! # Example +//! +//! ```no_run +//! use vdo::{Resolution, StreamBuilder, VdoFormat}; +//! +//! let stream = StreamBuilder::new() +//! .channel(0) +//! .format(VdoFormat::VDO_FORMAT_YUV) +//! .resolution(Resolution::Exact { width: 1920, height: 1080 }) +//! .build() +//! .expect("Failed to create stream"); +//! +//! let running = stream.start().expect("Failed to start stream"); +//! +//! for _ in 0..10 { +//! let buffer = running.next_buffer().expect("Failed to get buffer"); +//! println!("Frame size: {} bytes", buffer.size()); +//! } +//! +//! drop(running); +//! ``` +//! +//! # Known Issues +//! +//! - Image rotation may vary between platforms. Check the `rotation` property in stream info. +//! - Some formats (RGB, PLANAR_RGB) may produce upside-down images on certain platforms. + +mod map; +use std::{ + fmt::{Debug, Display}, + ptr, +}; + +use glib_sys::GError; +use gobject_sys::{g_object_unref, GObject}; +pub use map::{CStringPtr, Map}; +use vdo_sys::{VdoBuffer, VdoBufferStrategy, VdoStream}; +pub use vdo_sys::{VdoFormat, VdoFrameType, VdoRateControlMode, VdoRateControlPriority}; + +/// Macro for calling VDO functions that take a GError** parameter. +/// Returns a tuple of `(result, Option)`. +macro_rules! try_func { + ($func:path, $($arg:expr),+ $(,)?) => {{ + let mut error: *mut GError = ptr::null_mut(); + let success = $func($( $arg ),+, &mut error); + if error.is_null() { + (success, None) + } else { + (success, Some(Error::Vdo(VdoError::from_gerror(error)))) + } + }}; +} + +/// Error type for VDO operations. +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Vdo(#[from] VdoError), + #[error("VDO returned an unexpected null pointer")] + NullPointer, + #[error("Missing error data from VDO library")] + MissingVdoError, +} + +/// Error from the VDO library. +pub struct VdoError { + code: i32, + message: String, +} + +impl VdoError { + fn from_gerror(gerror: *mut GError) -> Self { + if gerror.is_null() { + return VdoError { + code: 0, + message: String::new(), + }; + } + + // SAFETY: gerror is non-null. We dereference the struct to copy its fields + // (code, message pointer), then read the message string, all before calling + // g_error_free which invalidates the GError and its contents. + let g_error = unsafe { *gerror }; + let message = if g_error.message.is_null() { + String::from("Unknown error") + } else { + unsafe { std::ffi::CStr::from_ptr(g_error.message) } + .to_str() + .unwrap_or("Invalid UTF-8 in error message") + .to_string() + }; + + unsafe { glib_sys::g_error_free(gerror) }; + + VdoError { + code: g_error.code, + message, + } + } + + /// Returns a human-readable name for the VDO error code. + pub fn code_name(&self) -> &'static str { + // Compare as i32 to avoid wrapping negative GError codes from non-VDO domains. + match self.code { + x if x == vdo_sys::VDO_ERROR_NOT_FOUND.0 as i32 => "VDO_ERROR_NOT_FOUND", + x if x == vdo_sys::VDO_ERROR_EXISTS.0 as i32 => "VDO_ERROR_EXISTS", + x if x == vdo_sys::VDO_ERROR_INVALID_ARGUMENT.0 as i32 => "VDO_ERROR_INVALID_ARGUMENT", + x if x == vdo_sys::VDO_ERROR_PERMISSION_DENIED.0 as i32 => { + "VDO_ERROR_PERMISSION_DENIED" + } + x if x == vdo_sys::VDO_ERROR_NOT_SUPPORTED.0 as i32 => "VDO_ERROR_NOT_SUPPORTED", + x if x == vdo_sys::VDO_ERROR_CLOSED.0 as i32 => "VDO_ERROR_CLOSED", + x if x == vdo_sys::VDO_ERROR_BUSY.0 as i32 => "VDO_ERROR_BUSY", + x if x == vdo_sys::VDO_ERROR_IO.0 as i32 => "VDO_ERROR_IO", + x if x == vdo_sys::VDO_ERROR_HAL.0 as i32 => "VDO_ERROR_HAL", + x if x == vdo_sys::VDO_ERROR_DBUS.0 as i32 => "VDO_ERROR_DBUS", + x if x == vdo_sys::VDO_ERROR_OOM.0 as i32 => "VDO_ERROR_OOM", + x if x == vdo_sys::VDO_ERROR_IDLE.0 as i32 => "VDO_ERROR_IDLE", + x if x == vdo_sys::VDO_ERROR_NO_DATA.0 as i32 => "VDO_ERROR_NO_DATA", + x if x == vdo_sys::VDO_ERROR_NO_BUFFER_SPACE.0 as i32 => "VDO_ERROR_NO_BUFFER_SPACE", + x if x == vdo_sys::VDO_ERROR_BUFFER_FAILURE.0 as i32 => "VDO_ERROR_BUFFER_FAILURE", + x if x == vdo_sys::VDO_ERROR_INTERFACE_DOWN.0 as i32 => "VDO_ERROR_INTERFACE_DOWN", + x if x == vdo_sys::VDO_ERROR_FAILED.0 as i32 => "VDO_ERROR_FAILED", + x if x == vdo_sys::VDO_ERROR_FATAL.0 as i32 => "VDO_ERROR_FATAL", + x if x == vdo_sys::VDO_ERROR_NOT_CONTROLLED.0 as i32 => "VDO_ERROR_NOT_CONTROLLED", + x if x == vdo_sys::VDO_ERROR_NO_EVENT.0 as i32 => "VDO_ERROR_NO_EVENT", + x if x == vdo_sys::VDO_ERROR_NO_VIDEO.0 as i32 => "VDO_ERROR_NO_VIDEO", + _ => "VDO_ERROR_UNKNOWN", + } + } + + pub fn code(&self) -> i32 { + self.code + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl Display for VdoError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({}): {}", self.code_name(), self.code, self.message) + } +} + +impl Debug for VdoError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VdoError") + .field("code", &self.code) + .field("code_name", &self.code_name()) + .field("message", &self.message) + .finish() + } +} + +impl std::error::Error for VdoError {} + +/// Specifies the video resolution for a stream. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Resolution { + /// Use the camera's native resolution. + Native, + /// Use an exact resolution. + Exact { width: u32, height: u32 }, +} + +/// Builder for creating a video stream. +/// +/// Use [`StreamBuilder::new()`] to create a new builder. +/// +/// # Example +/// +/// ```no_run +/// use vdo::{Resolution, StreamBuilder, VdoFormat}; +/// +/// let stream = StreamBuilder::new() +/// .channel(0) +/// .format(VdoFormat::VDO_FORMAT_H264) +/// .resolution(Resolution::Exact { width: 1920, height: 1080 }) +/// .framerate(30) +/// .build() +/// .expect("Failed to build stream"); +/// ``` +#[derive(Clone)] +pub struct StreamBuilder { + format: VdoFormat, + buffer_count: u32, + channel: u32, + resolution: Resolution, + framerate: u32, +} + +impl Default for StreamBuilder { + fn default() -> Self { + Self { + format: VdoFormat::VDO_FORMAT_H264, + buffer_count: 3, + channel: 0, + resolution: Resolution::Native, + framerate: 0, + } + } +} + +impl StreamBuilder { + pub fn new() -> Self { + Self::default() + } + + /// Default: `VdoFormat::VDO_FORMAT_H264` + /// + /// See the [platform compatibility table](crate#platform-compatibility) for supported formats. + pub fn format(mut self, format: VdoFormat) -> Self { + self.format = format; + self + } + + /// Default: 0 (main channel) + pub fn channel(mut self, channel: u32) -> Self { + self.channel = channel; + self + } + + /// Default: [`Resolution::Native`] + pub fn resolution(mut self, resolution: Resolution) -> Self { + self.resolution = resolution; + self + } + + /// If 0, the camera's default framerate is used. + pub fn framerate(mut self, framerate: u32) -> Self { + self.framerate = framerate; + self + } + + /// Default: 3. For YUV/RGB formats, controls frame buffer count. + /// For compressed formats (H.264, H.265, JPEG), typically ignored. + pub fn buffers(mut self, count: u32) -> Self { + self.buffer_count = count; + self + } + + /// Builds the stream. + /// + /// Returns an error if the stream could not be created (e.g., invalid format + /// for the platform, or camera not available). + pub fn build(self) -> std::result::Result { + let mut map = Map::new(); + map.set_u32(c"channel", self.channel); + map.set_u32(c"format", self.format.0 as u32); + if let Resolution::Exact { width, height } = self.resolution { + map.set_u32(c"width", width); + map.set_u32(c"height", height); + } + if self.framerate > 0 { + map.set_u32(c"framerate", self.framerate); + } + map.set_u32(c"buffer.count", self.buffer_count); + // Always use INFINITE strategy; EXPLICIT is not exposed because it + // requires unsafe application-managed buffer allocation. + map.set_u32( + c"buffer.strategy", + VdoBufferStrategy::VDO_BUFFER_STRATEGY_INFINITE.0, + ); + + let (stream_raw, maybe_error) = + unsafe { try_func!(vdo_sys::vdo_stream_new, map.as_ptr(), None) }; + + if stream_raw.is_null() { + return Err(maybe_error.unwrap_or(Error::MissingVdoError)); + } + + debug_assert!( + maybe_error.is_none(), + "vdo_stream_new returned a stream pointer AND an error" + ); + + Ok(Stream { + raw: stream_raw, + started: false, + }) + } +} + +/// A video stream from a camera channel. +/// +/// Use [`StreamBuilder`] to create a stream, then call [`Stream::start()`] +/// to begin capturing frames. Starting consumes the `Stream` and returns a +/// [`RunningStream`]. +/// +/// # Example +/// +/// ```no_run +/// use vdo::{Resolution, StreamBuilder, VdoFormat}; +/// +/// let stream = StreamBuilder::new() +/// .format(VdoFormat::VDO_FORMAT_JPEG) +/// .resolution(Resolution::Exact { width: 640, height: 480 }) +/// .build()?; +/// +/// let running = stream.start()?; +/// for _ in 0..5 { +/// let buffer = running.next_buffer()?; +/// println!("Got frame: {} bytes", buffer.size()); +/// } +/// drop(running); +/// # Ok::<(), vdo::Error>(()) +/// ``` +#[derive(Debug)] +pub struct Stream { + raw: *mut VdoStream, + started: bool, +} + +// SAFETY: We hold exclusive ownership of the raw pointer and the VDO SDK +// does not require streams to be pinned to a specific thread. +unsafe impl Send for Stream {} + +impl Stream { + /// Equivalent to `StreamBuilder::new().build()` (H.264 format, native resolution). + pub fn new() -> std::result::Result { + StreamBuilder::new().build() + } + + /// Returns stream information (actual resolution, format, etc.) as a map. + pub fn info(&self) -> std::result::Result { + let (map_raw, maybe_error) = unsafe { try_func!(vdo_sys::vdo_stream_get_info, self.raw) }; + if map_raw.is_null() { + return Err(maybe_error.unwrap_or(Error::MissingVdoError)); + } + // SAFETY: map_raw is non-null and freshly returned by VDO with ownership transferred. + Ok(unsafe { Map::from_raw(map_raw) }) + } + + /// Returns stream settings as a map. + pub fn settings(&self) -> std::result::Result { + let (map_raw, maybe_error) = + unsafe { try_func!(vdo_sys::vdo_stream_get_settings, self.raw) }; + if map_raw.is_null() { + return Err(maybe_error.unwrap_or(Error::MissingVdoError)); + } + Ok(unsafe { Map::from_raw(map_raw) }) + } + + /// Starts the stream, consuming `self` and returning a [`RunningStream`]. + /// + /// On failure, the stream is consumed and cannot be reused; create a new + /// stream via [`StreamBuilder`] to retry. + pub fn start(mut self) -> std::result::Result { + let (success, maybe_error) = unsafe { try_func!(vdo_sys::vdo_stream_start, self.raw) }; + if success == glib_sys::GFALSE { + return Err(maybe_error.unwrap_or(Error::MissingVdoError)); + } + self.started = true; + Ok(RunningStream { stream: self }) + } +} + +impl Drop for Stream { + fn drop(&mut self) { + if self.started { + unsafe { vdo_sys::vdo_stream_stop(self.raw) }; + } + // Release our GObject reference to avoid leaking. + unsafe { g_object_unref(self.raw as *mut GObject) }; + } +} + +/// A running video stream that yields frame buffers. +/// +/// Created by calling [`Stream::start()`]. Use [`next_buffer()`](RunningStream::next_buffer) +/// to retrieve frame buffers. Drop this value to stop the stream. +pub struct RunningStream { + stream: Stream, +} + +// SAFETY: Owns a Stream (which is Send) and the VDO SDK does not +// require streams to be pinned to a specific thread. +unsafe impl Send for RunningStream {} + +impl RunningStream { + /// Blocks until a new frame is available and returns it. + pub fn next_buffer(&self) -> std::result::Result, Error> { + let (buffer_ptr, maybe_error) = + unsafe { try_func!(vdo_sys::vdo_stream_get_buffer, self.stream.raw) }; + + if buffer_ptr.is_null() { + return Err(maybe_error.unwrap_or(Error::MissingVdoError)); + } + + Ok(StreamBuffer { + raw: buffer_ptr, + stream: &self.stream, + }) + } +} + +/// A buffer containing a video frame from a running stream. +/// +/// Since `VdoBuffer` and `VdoFrame` are the same type in the C API, all frame +/// metadata (size, timestamp, frame type, etc.) is accessed directly on this type. +/// +/// The buffer borrows from the [`RunningStream`] that produced it and is +/// automatically unreferenced when dropped. +/// +/// # Buffer Validity +/// +/// The buffer data pointer and frame metadata remain valid until the buffer is +/// unreferenced on drop. +pub struct StreamBuffer<'a> { + raw: *mut VdoBuffer, + stream: &'a Stream, +} + +impl StreamBuffer<'_> { + pub fn capacity(&self) -> usize { + unsafe { vdo_sys::vdo_buffer_get_capacity(self.raw) } + } + + /// Returns the frame data as a byte slice of [`capacity()`](StreamBuffer::capacity) bytes. + /// + /// Use [`size()`](StreamBuffer::size) to get the actual frame data size. + pub fn as_slice(&self) -> std::result::Result<&[u8], Error> { + let data = unsafe { vdo_sys::vdo_buffer_get_data(self.raw) }; + if data.is_null() { + return Err(Error::NullPointer); + } + // SAFETY: VDO buffers are backed by mmap'd or allocated regions that are fully + // initialized at allocation time. Bytes beyond size() may be stale but are valid. + let slice = unsafe { std::slice::from_raw_parts(data as *const u8, self.capacity()) }; + Ok(slice) + } + + /// Returns a copy of the frame data, excluding the header if one is present. + /// + /// Use [`as_slice()`](StreamBuffer::as_slice) for a raw view of the whole buffer. + pub fn data_copy(&self) -> std::result::Result, Error> { + let data = unsafe { vdo_sys::vdo_buffer_get_data(self.raw) }; + if data.is_null() { + return Err(Error::NullPointer); + } + let offset = self.header_size().unwrap_or(0); + let size = self.size(); + assert!(offset <= size, "expect header to fit within frame size"); + assert!( + size <= self.capacity(), + "expect frame size to fit within buffer capacity" + ); + // SAFETY: offset..size lies within the mapped region of capacity bytes, + // which is fully initialized at allocation time. + let slice = + unsafe { std::slice::from_raw_parts((data as *const u8).add(offset), size - offset) }; + Ok(slice.to_vec()) + } + + pub fn frame_type(&self) -> VdoFrameType { + unsafe { vdo_sys::vdo_frame_get_frame_type(self.raw) } + } + + /// Starts at 0 and increments with each frame. Wrap-around point is undefined. + pub fn sequence_number(&self) -> u32 { + unsafe { vdo_sys::vdo_frame_get_sequence_nbr(self.raw) } + } + + /// Timestamp in microseconds since boot. + pub fn timestamp(&self) -> u64 { + unsafe { vdo_sys::vdo_frame_get_timestamp(self.raw) } + } + + pub fn custom_timestamp_us(&self) -> i64 { + unsafe { vdo_sys::vdo_frame_get_custom_timestamp(self.raw) } + } + + /// Actual frame data size in bytes (may be less than [`capacity()`](StreamBuffer::capacity)). + pub fn size(&self) -> usize { + unsafe { vdo_sys::vdo_frame_get_size(self.raw) } + } + + /// Returns the header size in bytes, or `None` if the frame has no header. + pub fn header_size(&self) -> Option { + let size = unsafe { vdo_sys::vdo_frame_get_header_size(self.raw) }; + if size < 0 { + None + } else { + Some(size as usize) + } + } + + pub fn is_last_buffer(&self) -> bool { + unsafe { vdo_sys::vdo_frame_get_is_last_buffer(self.raw) != glib_sys::GFALSE } + } +} + +impl Drop for StreamBuffer<'_> { + fn drop(&mut self) { + let (success, maybe_error) = unsafe { + try_func!( + vdo_sys::vdo_stream_buffer_unref, + self.stream.raw, + &mut self.raw + ) + }; + if success == glib_sys::GFALSE || maybe_error.is_some() { + match maybe_error { + Some(err) => log::error!("Failed to unref buffer: {}", err), + None => log::error!("Failed to unref buffer (no GError details)"), + } + } + } +} + +#[cfg(test)] +mod unit_tests { + use expect_test::expect; + + use super::*; + + #[test] + fn error_code_names() { + let err = VdoError { + code: vdo_sys::VDO_ERROR_NOT_FOUND.0 as i32, + message: "test".to_string(), + }; + expect!["VDO_ERROR_NOT_FOUND"].assert_eq(err.code_name()); + + let err = VdoError { + code: vdo_sys::VDO_ERROR_NOT_SUPPORTED.0 as i32, + message: "test".to_string(), + }; + expect!["VDO_ERROR_NOT_SUPPORTED"].assert_eq(err.code_name()); + + let err = VdoError { + code: 9999, + message: "test".to_string(), + }; + expect!["VDO_ERROR_UNKNOWN"].assert_eq(err.code_name()); + + // Negative codes (from non-VDO GError domains) should map to UNKNOWN, + // not wrap to a matching VDO constant. + let err = VdoError { + code: -1, + message: "test".to_string(), + }; + expect!["VDO_ERROR_UNKNOWN"].assert_eq(err.code_name()); + } + + #[test] + fn error_display() { + let err = VdoError { + code: vdo_sys::VDO_ERROR_BUSY.0 as i32, + message: "Resource is busy".to_string(), + }; + expect!["VDO_ERROR_BUSY (7): Resource is busy"].assert_eq(&format!("{err}")); + } + + #[test] + fn stream_builder_defaults() { + let builder = StreamBuilder::default(); + assert_eq!(builder.format, VdoFormat::VDO_FORMAT_H264); + assert_eq!(builder.channel, 0); + assert_eq!(builder.buffer_count, 3); + assert_eq!(builder.resolution, Resolution::Native); + } + + #[test] + fn stream_builder_chaining() { + let builder = StreamBuilder::new() + .format(VdoFormat::VDO_FORMAT_JPEG) + .channel(1) + .resolution(Resolution::Exact { + width: 1280, + height: 720, + }) + .framerate(30) + .buffers(5); + + assert_eq!(builder.format, VdoFormat::VDO_FORMAT_JPEG); + assert_eq!(builder.channel, 1); + assert_eq!( + builder.resolution, + Resolution::Exact { + width: 1280, + height: 720 + } + ); + assert_eq!(builder.framerate, 30); + assert_eq!(builder.buffer_count, 5); + } + + #[test] + fn error_is_send() { + fn assert_send() {} + assert_send::(); + } + + #[test] + fn vdo_error_from_null() { + let err = VdoError::from_gerror(ptr::null_mut()); + assert_eq!(err.code(), 0); + assert!(err.message().is_empty()); + } + + #[test] + fn error_from_vdo_error() { + let vdo_err = VdoError { + code: 1, + message: "test".to_string(), + }; + let err: Error = Error::from(vdo_err); + match err { + Error::Vdo(e) => { + assert_eq!(e.code(), 1); + assert_eq!(e.message(), "test"); + } + _ => panic!("Expected Error::Vdo"), + } + } + + #[test] + fn all_error_variants_display() { + expect!["VDO returned an unexpected null pointer"] + .assert_eq(&format!("{}", Error::NullPointer)); + expect!["Missing error data from VDO library"] + .assert_eq(&format!("{}", Error::MissingVdoError)); + let vdo = Error::Vdo(VdoError { + code: 1, + message: "test".to_string(), + }); + expect!["VDO_ERROR_NOT_FOUND (1): test"].assert_eq(&format!("{vdo}")); + } +} + +// These tests require the VDO shared library (libvdo.so) and actual camera hardware. +// Results depend on the specific camera model and firmware. +#[cfg(not(any(target_arch = "x86_64", target_os = "macos")))] +#[cfg(test)] +mod tests { + use super::*; + + fn init_logger() { + let _ = env_logger::builder().is_test(true).try_init(); + } + + #[test] + fn stream_starts_and_stops() -> std::result::Result<(), Box> { + init_logger(); + let stream = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_YUV) + .resolution(Resolution::Exact { + width: 640, + height: 480, + }) + .build()?; + + let running = stream.start()?; + drop(running); + Ok(()) + } + + #[test] + fn stream_new_default() -> std::result::Result<(), Box> { + init_logger(); + let stream = Stream::new()?; + let _info = stream.info()?; + Ok(()) + } + + #[test] + fn native_resolution() -> std::result::Result<(), Box> { + init_logger(); + let stream = StreamBuilder::new() + .format(VdoFormat::VDO_FORMAT_YUV) + .build()?; + + let running = stream.start()?; + let buffer = running.next_buffer()?; + assert!(buffer.size() > 0); + drop(buffer); + drop(running); + Ok(()) + } + + #[test] + fn stream_info_available() -> std::result::Result<(), Box> { + init_logger(); + let stream = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_YUV) + .resolution(Resolution::Exact { + width: 640, + height: 480, + }) + .build()?; + + let info = stream.info()?; + info.dump(); + Ok(()) + } + + #[test] + fn stream_settings_available() -> std::result::Result<(), Box> { + init_logger(); + let stream = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_YUV) + .resolution(Resolution::Exact { + width: 640, + height: 480, + }) + .build()?; + + let settings = stream.settings()?; + settings.dump(); + Ok(()) + } + + #[test] + fn capture_yuv_frames() -> std::result::Result<(), Box> { + init_logger(); + let stream = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_YUV) + .resolution(Resolution::Exact { + width: 640, + height: 480, + }) + .build()?; + + let running = stream.start()?; + + for i in 0..5 { + let buffer = running.next_buffer()?; + let size = buffer.size(); + assert!(size > 0, "Frame {} size should be > 0", i); + + // YUV NV12: width * height * 1.5 bytes + let expected_min = (640 * 480) as usize; + assert!( + size >= expected_min, + "YUV frame too small: {} < {}", + size, + expected_min + ); + + log::info!( + "YUV frame {}: {} bytes, seq={}, ts={}", + i, + size, + buffer.sequence_number(), + buffer.timestamp() + ); + } + + drop(running); + Ok(()) + } + + #[test] + fn capture_jpeg_frames() -> std::result::Result<(), Box> { + init_logger(); + let stream = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_JPEG) + .resolution(Resolution::Exact { + width: 640, + height: 480, + }) + .build()?; + + let running = stream.start()?; + + for i in 0..5 { + let buffer = running.next_buffer()?; + assert!(buffer.size() > 0, "Frame {} size should be > 0", i); + + let data = buffer.as_slice()?; + assert!(data.len() >= 2, "Buffer too small for JPEG"); + assert_eq!(data[0], 0xFF, "Invalid JPEG SOI marker"); + assert_eq!(data[1], 0xD8, "Invalid JPEG SOI marker"); + + log::info!("JPEG frame {}: {} bytes", i, buffer.size()); + } + + drop(running); + Ok(()) + } + + #[test] + fn capture_h264_frames() -> std::result::Result<(), Box> { + init_logger(); + let stream = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_H264) + .resolution(Resolution::Exact { + width: 640, + height: 480, + }) + .build()?; + + let running = stream.start()?; + + let mut got_i_frame = false; + + for _ in 0..30 { + let buffer = running.next_buffer()?; + assert!(buffer.size() > 0, "H.264 frame size should be > 0"); + + match buffer.frame_type() { + VdoFrameType::VDO_FRAME_TYPE_H264_IDR | VdoFrameType::VDO_FRAME_TYPE_H264_I => { + got_i_frame = true; + } + _ => {} + } + + log::info!( + "H.264 frame: {} bytes, type={:?}, seq={}", + buffer.size(), + buffer.frame_type(), + buffer.sequence_number() + ); + } + + assert!(got_i_frame, "Should have captured at least one I-frame"); + + drop(running); + Ok(()) + } + + /// Skips gracefully on platforms without H.265 support (e.g., Artpec-6). + #[test] + fn capture_h265_frames() -> std::result::Result<(), Box> { + init_logger(); + + let stream_result = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_H265) + .resolution(Resolution::Exact { + width: 640, + height: 480, + }) + .build(); + + match stream_result { + Ok(stream) => { + let running = stream.start()?; + + for _ in 0..10 { + let buffer = running.next_buffer()?; + assert!(buffer.size() > 0, "H.265 frame size should be > 0"); + log::info!("H.265 frame: {} bytes", buffer.size()); + } + + drop(running); + } + Err(Error::Vdo(e)) if e.code_name() == "VDO_ERROR_NOT_SUPPORTED" => { + log::info!("H.265 not supported on this platform, skipping"); + } + Err(e) => return Err(e.into()), + } + + Ok(()) + } + + #[test] + fn frame_timestamps_increase() -> std::result::Result<(), Box> { + init_logger(); + let stream = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_YUV) + .resolution(Resolution::Exact { + width: 320, + height: 240, + }) + .framerate(15) + .build()?; + + let running = stream.start()?; + let mut prev_ts = 0u64; + let mut prev_seq = 0u32; + + for i in 0..10 { + let buffer = running.next_buffer()?; + let ts = buffer.timestamp(); + let seq = buffer.sequence_number(); + + if i > 0 { + assert!( + ts > prev_ts, + "Timestamp should increase: {} <= {}", + ts, + prev_ts + ); + assert!( + seq > prev_seq, + "Sequence should increase: {} <= {}", + seq, + prev_seq + ); + } + + prev_ts = ts; + prev_seq = seq; + } + + drop(running); + Ok(()) + } + + #[test] + fn buffer_data_accessible() -> std::result::Result<(), Box> { + init_logger(); + let stream = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_YUV) + .resolution(Resolution::Exact { + width: 320, + height: 240, + }) + .build()?; + + let running = stream.start()?; + + for _ in 0..3 { + let buffer = running.next_buffer()?; + let capacity = buffer.capacity(); + let data = buffer.as_slice()?; + + assert_eq!(data.len(), capacity, "Slice length should match capacity"); + assert!( + buffer.size() <= capacity, + "Frame size should be <= capacity" + ); + + std::hint::black_box(data[0]); + std::hint::black_box(data[buffer.size().saturating_sub(1)]); + } + + drop(running); + Ok(()) + } + + #[test] + fn data_copy_returns_frame_data() -> std::result::Result<(), Box> { + init_logger(); + let stream = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_YUV) + .resolution(Resolution::Exact { + width: 320, + height: 240, + }) + .build()?; + + let running = stream.start()?; + let buffer = running.next_buffer()?; + + let copy = buffer.data_copy()?; + let offset = buffer.header_size().unwrap_or(0); + assert_eq!(copy.len(), buffer.size() - offset); + + // Verify copy matches the original slice, past the header if any + let slice = buffer.as_slice()?; + assert_eq!(©[..], &slice[offset..offset + copy.len()]); + + drop(buffer); + drop(running); + Ok(()) + } + + #[test] + fn all_buffer_metadata_accessible() -> std::result::Result<(), Box> { + init_logger(); + let stream = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_YUV) + .resolution(Resolution::Exact { + width: 320, + height: 240, + }) + .build()?; + + let running = stream.start()?; + let buffer = running.next_buffer()?; + + // Exercise all metadata accessors + std::hint::black_box(buffer.size()); + std::hint::black_box(buffer.capacity()); + std::hint::black_box(buffer.frame_type()); + std::hint::black_box(buffer.sequence_number()); + std::hint::black_box(buffer.timestamp()); + std::hint::black_box(buffer.custom_timestamp_us()); + std::hint::black_box(buffer.header_size()); + std::hint::black_box(buffer.is_last_buffer()); + + drop(buffer); + drop(running); + Ok(()) + } + + #[test] + fn multiple_streams_sequential() -> std::result::Result<(), Box> { + init_logger(); + + { + let stream = StreamBuilder::new() + .format(VdoFormat::VDO_FORMAT_YUV) + .resolution(Resolution::Exact { + width: 320, + height: 240, + }) + .build()?; + + let running = stream.start()?; + for _ in 0..3 { + let _buf = running.next_buffer()?; + } + drop(running); + } + + { + let stream = StreamBuilder::new() + .format(VdoFormat::VDO_FORMAT_JPEG) + .resolution(Resolution::Exact { + width: 320, + height: 240, + }) + .build()?; + + let running = stream.start()?; + for _ in 0..3 { + let _buf = running.next_buffer()?; + } + drop(running); + } + + Ok(()) + } + + /// Two streams open simultaneously, polled round-robin from one thread. + /// May fail if the camera doesn't support multiple concurrent streams. + #[test] + fn interleaved_streams() -> std::result::Result<(), Box> { + init_logger(); + + let stream1 = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_YUV) + .resolution(Resolution::Exact { + width: 320, + height: 240, + }) + .build()?; + + let stream2 = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_JPEG) + .resolution(Resolution::Exact { + width: 320, + height: 240, + }) + .build()?; + + let running1 = stream1.start()?; + let running2 = stream2.start()?; + + for _ in 0..3 { + let _buf1 = running1.next_buffer()?; + let _buf2 = running2.next_buffer()?; + } + + drop(running1); + drop(running2); + + Ok(()) + } + + #[test] + fn invalid_channel_returns_error() { + init_logger(); + let result = StreamBuilder::new() + .channel(999) + .format(VdoFormat::VDO_FORMAT_YUV) + .resolution(Resolution::Exact { + width: 640, + height: 480, + }) + .build(); + + assert!(result.is_err(), "Invalid channel should return error"); + if let Err(e) = result { + log::info!("Expected error for invalid channel: {}", e); + } + } + + /// Observational test: result is platform-dependent, logged but not asserted. + #[test] + fn unsupported_format_logged() { + init_logger(); + let result = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_BAYER) + .resolution(Resolution::Exact { + width: 640, + height: 480, + }) + .build(); + + match result { + Ok(_) => log::info!("BAYER format is supported on this platform"), + Err(e) => log::info!("BAYER format not supported: {}", e), + } + } + + /// Observational test: the camera may adjust or reject unusual resolutions. + #[test] + fn invalid_resolution_logged() { + init_logger(); + let result = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_YUV) + .resolution(Resolution::Exact { + width: 12345, + height: 6789, + }) + .build(); + + match result { + Ok(stream) => { + if let Ok(info) = stream.info() { + log::info!("Camera accepted unusual resolution, check actual via info"); + info.dump(); + } + } + Err(e) => { + log::info!("Camera rejected unusual resolution: {}", e); + } + } + } + + /// Tests that dropping a RunningStream without explicit drop doesn't crash. + #[test] + fn stream_dropped_without_stop() -> std::result::Result<(), Box> { + init_logger(); + + { + let stream = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_YUV) + .resolution(Resolution::Exact { + width: 320, + height: 240, + }) + .build()?; + + let running = stream.start()?; + for _ in 0..2 { + let _buf = running.next_buffer()?; + } + // Intentionally NOT calling drop(running) + } + + log::info!("Stream dropped without explicit stop - cleanup successful"); + Ok(()) + } + + #[test] + fn error_message_is_descriptive() { + init_logger(); + + let err = StreamBuilder::new() + .channel(999) + .build() + .expect_err("Channel 999 should fail"); + + match err { + Error::Vdo(e) => { + assert!( + !e.code_name().is_empty(), + "Error code name should not be empty" + ); + assert!(!e.message().is_empty(), "Error message should not be empty"); + } + other => panic!("Expected Error::Vdo, got: {:?}", other), + } + } + + #[test] + fn rapid_stream_creation_destruction() -> std::result::Result<(), Box> { + init_logger(); + + for i in 0..5 { + let stream = StreamBuilder::new() + .channel(0) + .format(VdoFormat::VDO_FORMAT_YUV) + .resolution(Resolution::Exact { + width: 320, + height: 240, + }) + .build()?; + + let running = stream.start()?; + drop(running.next_buffer()?); + drop(running); + + log::info!("Rapid cycle {} complete", i); + } + + Ok(()) + } + + // This test only requires libvdo.so, not camera hardware, but is placed here + // because the VDO library is only available on the device. + #[test] + fn map_get_set_operations() -> std::result::Result<(), Box> { + init_logger(); + + let mut map = Map::new(); + + map.set_u32(c"test_u32", 42); + assert_eq!(map.get_u32(c"test_u32", 0), 42); + assert_eq!(map.get_u32(c"missing_key", 99), 99); + + map.set_bool(c"test_bool", true); + assert!(map.get_bool(c"test_bool", false)); + assert!(!map.get_bool(c"missing_bool", false)); + + map.set_string(c"test_str", c"hello"); + let value = map.get_string(c"test_str"); + assert!(value.is_some()); + assert_eq!(value.unwrap().as_c_str().to_str().unwrap(), "hello"); + assert!(map.get_string(c"missing_str").is_none()); + + Ok(()) + } +} diff --git a/crates/vdo/src/map.rs b/crates/vdo/src/map.rs new file mode 100644 index 00000000..c8cd0cc8 --- /dev/null +++ b/crates/vdo/src/map.rs @@ -0,0 +1,172 @@ +//! Key-value map for VDO settings and a GLib-allocated C string type. + +use std::{ + ffi::{c_char, c_void, CStr}, + fmt, + ops::Deref, + ptr::{self, NonNull}, +}; + +use glib::translate::{from_glib, IntoGlib}; +use gobject_sys::{g_object_unref, GObject}; +use vdo_sys::VdoMap; + +/// An owned pointer to a C string allocated by GLib. +/// +/// The string is freed with `g_free` when dropped. +#[repr(transparent)] +pub struct CStringPtr(NonNull); + +impl CStringPtr { + /// # Safety + /// + /// The memory must satisfy the preconditions for [`CStr::from_ptr`], must have been + /// allocated in a manner compatible with [`glib_sys::g_free`], and there must be no other + /// users of this memory. + /// + /// # Panics + /// + /// Panics if `ptr` is null. + pub(crate) unsafe fn from_ptr(ptr: *mut c_char) -> Self { + Self(NonNull::new(ptr).expect("CStringPtr::from_ptr called with null")) + } + + pub fn as_c_str(&self) -> &CStr { + // SAFETY: The preconditions for instantiating this type include all preconditions + // for `CStr::from_ptr`. + unsafe { CStr::from_ptr(self.0.as_ptr() as *const c_char) } + } +} + +impl Deref for CStringPtr { + type Target = CStr; + + fn deref(&self) -> &CStr { + self.as_c_str() + } +} + +impl fmt::Debug for CStringPtr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self.as_c_str()) + } +} + +impl Drop for CStringPtr { + fn drop(&mut self) { + // SAFETY: We have full ownership, allocated in a manner compatible with `g_free`. + unsafe { + glib_sys::g_free(self.0.as_ptr() as *mut c_void); + } + } +} + +/// A key-value map for VDO settings. +/// +/// Used to configure stream parameters and retrieve stream information. +/// All methods assume `self.raw` is a valid `VdoMap` pointer, which is +/// guaranteed by the constructors. +pub struct Map { + raw: *mut VdoMap, +} + +impl Map { + pub fn new() -> Self { + // `vdo_map_new` is a thin wrapper around `g_object_new`, which aborts + // the program if allocation fails, so the returned pointer is never null. + let map = unsafe { vdo_sys::vdo_map_new() }; + Self { raw: map } + } + + /// # Safety + /// + /// `ptr` must be a valid `VdoMap` pointer with ownership + /// transferred to this `Map` (it will be unreferenced on drop). + /// + /// # Panics + /// + /// Panics if `ptr` is null. + pub(crate) unsafe fn from_raw(ptr: *mut VdoMap) -> Self { + assert!(!ptr.is_null(), "Map::from_raw called with null"); + Self { raw: ptr } + } + + pub fn set_u32(&mut self, key: &CStr, value: u32) { + unsafe { vdo_sys::vdo_map_set_uint32(self.raw, key.as_ptr(), value) } + } + + pub fn get_u32(&self, key: &CStr, default: u32) -> u32 { + unsafe { vdo_sys::vdo_map_get_uint32(self.raw, key.as_ptr(), default) } + } + + pub fn set_i32(&mut self, key: &CStr, value: i32) { + unsafe { vdo_sys::vdo_map_set_int32(self.raw, key.as_ptr(), value) } + } + + pub fn get_i32(&self, key: &CStr, default: i32) -> i32 { + unsafe { vdo_sys::vdo_map_get_int32(self.raw, key.as_ptr(), default) } + } + + pub fn set_string(&mut self, key: &CStr, value: &CStr) { + unsafe { vdo_sys::vdo_map_set_string(self.raw, key.as_ptr(), value.as_ptr()) } + } + + /// Returns `None` if the key doesn't exist or the value is null. + pub fn get_string(&self, key: &CStr) -> Option { + // Passing null as default so missing keys yield null -> None. + let ptr = + unsafe { vdo_sys::vdo_map_dup_string(self.raw, key.as_ptr(), ptr::null::()) }; + if ptr.is_null() { + return None; + } + // SAFETY: ptr is non-null, allocated by g_malloc via vdo_map_dup_string, and we own it. + Some(unsafe { CStringPtr::from_ptr(ptr) }) + } + + pub fn set_bool(&mut self, key: &CStr, value: bool) { + unsafe { vdo_sys::vdo_map_set_boolean(self.raw, key.as_ptr(), value.into_glib()) } + } + + pub fn get_bool(&self, key: &CStr, default: bool) -> bool { + unsafe { + from_glib(vdo_sys::vdo_map_get_boolean( + self.raw, + key.as_ptr(), + default.into_glib(), + )) + } + } + + /// Dumps the map contents to stdout. Intended for debugging only; + /// may expose sensitive configuration values in production logs. + pub fn dump(&self) { + unsafe { vdo_sys::vdo_map_dump(self.raw) } + } + + // Returns *mut because GLib's C API takes *mut even for read-only operations. + pub(crate) fn as_ptr(&self) -> *mut VdoMap { + self.raw + } +} + +impl Default for Map { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Debug for Map { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Map").field("raw", &self.raw).finish() + } +} + +// SAFETY: We hold exclusive ownership of the raw pointer and VdoMap +// does not require access from a specific thread. +unsafe impl Send for Map {} + +impl Drop for Map { + fn drop(&mut self) { + unsafe { g_object_unref(self.raw as *mut GObject) } + } +}