diff --git a/libasi/Cargo.toml b/libasi/Cargo.toml index 519a241..1170109 100644 --- a/libasi/Cargo.toml +++ b/libasi/Cargo.toml @@ -7,4 +7,5 @@ edition = "2024" [dependencies] libasi-sys = { version = "0.1.0", path = "../libasi-sys" } -log = "0.4" \ No newline at end of file +log = "0.4" +thiserror = "1" \ No newline at end of file diff --git a/libasi/src/camera.rs b/libasi/src/camera.rs index 977923b..78def47 100644 --- a/libasi/src/camera.rs +++ b/libasi/src/camera.rs @@ -1,5 +1,4 @@ pub use libasi_sys::camera::*; -use log::error; pub type AsiCameraInfo = _ASI_CAMERA_INFO; pub type AsiControlCaps = _ASI_CONTROL_CAPS; @@ -13,100 +12,154 @@ pub struct ROIFormat { pub img_type: i32, } -fn check_error_code(code: i32) { +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum AsiCameraError { + #[error("invalid camera index")] + InvalidIndex, + #[error("invalid camera id")] + InvalidId, + #[error("invalid control type")] + InvalidControlType, + #[error("camera not open")] + CameraClosed, + #[error("camera removed")] + CameraRemoved, + #[error("invalid path")] + InvalidPath, + #[error("invalid file format")] + InvalidFileFormat, + #[error("invalid size")] + InvalidSize, + #[error("invalid image type")] + InvalidImgType, + #[error("start position out of boundary")] + OutOfBoundary, + #[error("communication timeout")] + Timeout, + #[error("invalid sequence — stop capture first")] + InvalidSequence, + #[error("buffer too small")] + BufferTooSmall, + #[error("video mode active")] + VideoModeActive, + #[error("exposure in progress")] + ExposureInProgress, + #[error("general error")] + GeneralError, + #[error("invalid mode")] + InvalidMode, + #[error("end sentinel")] + End, + #[error("unknown error code: {0}")] + Unknown(i32), +} + +fn check_error_code(code: i32) -> Result<(), AsiCameraError> { match code { - // Success - 0 => (), - // No camera connected or index value out of boundary - 1 => error!("ASI_ERROR_INVALID_INDEX"), - 2 => error!("ASI_ERROR_INVALID_ID"), - 3 => error!("ASI_ERROR_INVALID_CONTROL_TYPE"), - // Camera didn't open - 4 => error!("ASI_ERROR_CAMERA_CLOSED"), - // Failed to find the camera, maybe the camera has been removed - 5 => error!("ASI_ERROR_CAMERA_REMOVED"), - // Cannot find the path of the file - 6 => error!("ASI_ERROR_INVALID_PATH"), - 7 => error!("ASI_ERROR_INVALID_FILEFORMAT"), - // Wrong video format size - 8 => error!("ASI_ERROR_INVALID_SIZE"), - 9 => error!("ASI_ERROR_INVALID_IMGTYPE"), //unsupported image formate - 10 => error!("ASI_ERROR_OUTOF_BOUNDARY"), //the startpos is out of boundary - // Communication timeout - 11 => error!("ASI_ERROR_TIMEOUT"), - 12 => error!("ASI_ERROR_INVALID_SEQUENCE"), //stop capture first! - 13 => error!("ASI_ERROR_BUFFER_TOO_SMALL"), //buffer size is not big enough - 14 => error!("ASI_ERROR_VIDEO_MODE_ACTIVE"), - 15 => error!("ASI_ERROR_EXPOSURE_IN_PROGRESS"), - 16 => error!("ASI_ERROR_GENERAL_ERROR"), //general error, eg: value is out of valid range - 17 => error!("ASI_ERROR_INVALID_MODE"), //the current mode is wrong - 18 => error!("ASI_ERROR_END"), - e => error!("unknown error {}", e), + 0 => Ok(()), + 1 => Err(AsiCameraError::InvalidIndex), + 2 => Err(AsiCameraError::InvalidId), + 3 => Err(AsiCameraError::InvalidControlType), + 4 => Err(AsiCameraError::CameraClosed), + 5 => Err(AsiCameraError::CameraRemoved), + 6 => Err(AsiCameraError::InvalidPath), + 7 => Err(AsiCameraError::InvalidFileFormat), + 8 => Err(AsiCameraError::InvalidSize), + 9 => Err(AsiCameraError::InvalidImgType), + 10 => Err(AsiCameraError::OutOfBoundary), + 11 => Err(AsiCameraError::Timeout), + 12 => Err(AsiCameraError::InvalidSequence), + 13 => Err(AsiCameraError::BufferTooSmall), + 14 => Err(AsiCameraError::VideoModeActive), + 15 => Err(AsiCameraError::ExposureInProgress), + 16 => Err(AsiCameraError::GeneralError), + 17 => Err(AsiCameraError::InvalidMode), + 18 => Err(AsiCameraError::End), + n => Err(AsiCameraError::Unknown(n)), } } -pub fn start_exposure(camera_id: i32) { - check_error_code(unsafe { libasi_sys::camera::ASIStartExposure(camera_id, 0) }); +pub fn start_exposure(camera_id: i32) -> Result<(), AsiCameraError> { + check_error_code(unsafe { libasi_sys::camera::ASIStartExposure(camera_id, 0) }) } -pub fn stop_exposure(camera_id: i32) { - check_error_code(unsafe { libasi_sys::camera::ASIStopExposure(camera_id) }); +pub fn stop_exposure(camera_id: i32) -> Result<(), AsiCameraError> { + check_error_code(unsafe { libasi_sys::camera::ASIStopExposure(camera_id) }) } #[cfg(windows)] -pub fn exposure_status(camera_id: i32, status: *mut i32) { - check_error_code(unsafe { libasi_sys::camera::ASIGetExpStatus(camera_id, status) }); +pub fn exposure_status(camera_id: i32, status: *mut i32) -> Result<(), AsiCameraError> { + check_error_code(unsafe { libasi_sys::camera::ASIGetExpStatus(camera_id, status) }) } #[cfg(unix)] -pub fn exposure_status(camera_id: i32, status: *mut u32) { - check_error_code(unsafe { libasi_sys::camera::ASIGetExpStatus(camera_id, status) }); +pub fn exposure_status(camera_id: i32, status: *mut u32) -> Result<(), AsiCameraError> { + check_error_code(unsafe { libasi_sys::camera::ASIGetExpStatus(camera_id, status) }) } #[cfg(windows)] -pub fn download_exposure(camera_id: i32, buffer: *mut u8, buf_size: i32) { - check_error_code(unsafe { libasi_sys::camera::ASIGetDataAfterExp(camera_id, buffer, buf_size) }); +pub fn download_exposure( + camera_id: i32, + buffer: *mut u8, + buf_size: i32, +) -> Result<(), AsiCameraError> { + check_error_code(unsafe { + libasi_sys::camera::ASIGetDataAfterExp(camera_id, buffer, buf_size) + }) } #[cfg(unix)] -pub fn download_exposure(camera_id: i32, buffer: *mut u8, buf_size: i64) { - check_error_code(unsafe { libasi_sys::camera::ASIGetDataAfterExp(camera_id, buffer, buf_size) }); +pub fn download_exposure( + camera_id: i32, + buffer: *mut u8, + buf_size: i64, +) -> Result<(), AsiCameraError> { + check_error_code(unsafe { + libasi_sys::camera::ASIGetDataAfterExp(camera_id, buffer, buf_size) + }) } pub fn get_num_of_connected_cameras() -> i32 { unsafe { libasi_sys::camera::ASIGetNumOfConnectedCameras() } } -pub fn get_cam_id(camera_id: i32, asi_id: *mut AsiID) { - check_error_code(unsafe { libasi_sys::camera::ASIGetID(camera_id, asi_id) }); +pub fn get_cam_id(camera_id: i32, asi_id: *mut AsiID) -> Result<(), AsiCameraError> { + check_error_code(unsafe { libasi_sys::camera::ASIGetID(camera_id, asi_id) }) } -pub fn set_cam_id(camera_id: i32, asi_id: AsiID) { - check_error_code(unsafe { libasi_sys::camera::ASISetID(camera_id, asi_id) }); +pub fn set_cam_id(camera_id: i32, asi_id: AsiID) -> Result<(), AsiCameraError> { + check_error_code(unsafe { libasi_sys::camera::ASISetID(camera_id, asi_id) }) } -pub fn open_camera(camera_index: i32) { - check_error_code(unsafe { libasi_sys::camera::ASIOpenCamera(camera_index) }); +pub fn open_camera(camera_index: i32) -> Result<(), AsiCameraError> { + check_error_code(unsafe { libasi_sys::camera::ASIOpenCamera(camera_index) }) } -pub fn init_camera(camera_index: i32) { - check_error_code(unsafe { libasi_sys::camera::ASIInitCamera(camera_index) }); +pub fn init_camera(camera_index: i32) -> Result<(), AsiCameraError> { + check_error_code(unsafe { libasi_sys::camera::ASIInitCamera(camera_index) }) } -pub fn close_camera(camera_index: i32) { - check_error_code(unsafe { libasi_sys::camera::ASICloseCamera(camera_index) }); +pub fn close_camera(camera_index: i32) -> Result<(), AsiCameraError> { + check_error_code(unsafe { libasi_sys::camera::ASICloseCamera(camera_index) }) } -pub fn get_control_caps(camera_id: i32, index: i32, noc: *mut AsiControlCaps) { - check_error_code(unsafe { libasi_sys::camera::ASIGetControlCaps(camera_id, index, noc) }); +pub fn get_control_caps( + camera_id: i32, + index: i32, + noc: *mut AsiControlCaps, +) -> Result<(), AsiCameraError> { + check_error_code(unsafe { libasi_sys::camera::ASIGetControlCaps(camera_id, index, noc) }) } -pub fn get_num_of_controls(camera_index: i32, noc: *mut i32) { - check_error_code(unsafe { libasi_sys::camera::ASIGetNumOfControls(camera_index, noc) }); +pub fn get_num_of_controls(camera_index: i32, noc: *mut i32) -> Result<(), AsiCameraError> { + check_error_code(unsafe { libasi_sys::camera::ASIGetNumOfControls(camera_index, noc) }) } -pub fn get_camera_info(asi_info: *mut AsiCameraInfo, camera_index: i32) { - check_error_code(unsafe { libasi_sys::camera::ASIGetCameraProperty(asi_info, camera_index) }); +pub fn get_camera_info( + asi_info: *mut AsiCameraInfo, + camera_index: i32, +) -> Result<(), AsiCameraError> { + check_error_code(unsafe { libasi_sys::camera::ASIGetCameraProperty(asi_info, camera_index) }) } #[cfg(windows)] @@ -115,10 +168,10 @@ pub fn get_control_value( control_type: i32, value: &mut i32, is_auto_set: &mut i32, -) { +) -> Result<(), AsiCameraError> { check_error_code(unsafe { libasi_sys::camera::ASIGetControlValue(camera_index, control_type, value, is_auto_set) - }); + }) } #[cfg(unix)] @@ -127,24 +180,34 @@ pub fn get_control_value( control_type: i32, value: &mut i64, is_auto_set: &mut i32, -) { +) -> Result<(), AsiCameraError> { check_error_code(unsafe { libasi_sys::camera::ASIGetControlValue(camera_index, control_type, value, is_auto_set) - }); + }) } #[cfg(windows)] -pub fn set_control_value(camera_index: i32, control_type: i32, value: i32, is_auto_set: i32) { +pub fn set_control_value( + camera_index: i32, + control_type: i32, + value: i32, + is_auto_set: i32, +) -> Result<(), AsiCameraError> { check_error_code(unsafe { libasi_sys::camera::ASISetControlValue(camera_index, control_type, value, is_auto_set) - }); + }) } #[cfg(unix)] -pub fn set_control_value(camera_index: i32, control_type: i32, value: ::std::os::raw::c_long, is_auto_set: i32) { +pub fn set_control_value( + camera_index: i32, + control_type: i32, + value: ::std::os::raw::c_long, + is_auto_set: i32, +) -> Result<(), AsiCameraError> { check_error_code(unsafe { libasi_sys::camera::ASISetControlValue(camera_index, control_type, value, is_auto_set) - }); + }) } pub fn get_roi_format( @@ -153,10 +216,10 @@ pub fn get_roi_format( height: &mut i32, bin: &mut i32, img_type: &mut i32, -) { +) -> Result<(), AsiCameraError> { check_error_code(unsafe { libasi_sys::camera::ASIGetROIFormat(camera_id, width, height, bin, img_type) - }); + }) } pub fn set_roi_format( @@ -165,20 +228,58 @@ pub fn set_roi_format( height: i32, bin: i32, img_type: i32, -) { +) -> Result<(), AsiCameraError> { check_error_code(unsafe { libasi_sys::camera::ASISetROIFormat(camera_id, width, height, bin, img_type) - }); + }) } -pub fn get_start_position(cam_idx: i32, start_x: &mut i32, start_y: &mut i32) { - check_error_code(unsafe { - libasi_sys::camera::ASIGetStartPos(cam_idx, start_x, start_y) - }); +pub fn get_start_position( + cam_idx: i32, + start_x: &mut i32, + start_y: &mut i32, +) -> Result<(), AsiCameraError> { + check_error_code(unsafe { libasi_sys::camera::ASIGetStartPos(cam_idx, start_x, start_y) }) } -pub fn get_camera_mode(cam_idx: i32, camera_mode: &mut i32) { - check_error_code(unsafe { - libasi_sys::camera::ASIGetCameraMode(cam_idx, camera_mode) - }); +pub fn get_camera_mode(cam_idx: i32, camera_mode: &mut i32) -> Result<(), AsiCameraError> { + check_error_code(unsafe { libasi_sys::camera::ASIGetCameraMode(cam_idx, camera_mode) }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn success_code_is_ok() { + assert!(check_error_code(0).is_ok()); + } + + #[test] + fn known_error_codes_map_correctly() { + assert_eq!(check_error_code(1), Err(AsiCameraError::InvalidIndex)); + assert_eq!(check_error_code(2), Err(AsiCameraError::InvalidId)); + assert_eq!(check_error_code(3), Err(AsiCameraError::InvalidControlType)); + assert_eq!(check_error_code(4), Err(AsiCameraError::CameraClosed)); + assert_eq!(check_error_code(5), Err(AsiCameraError::CameraRemoved)); + assert_eq!(check_error_code(6), Err(AsiCameraError::InvalidPath)); + assert_eq!(check_error_code(7), Err(AsiCameraError::InvalidFileFormat)); + assert_eq!(check_error_code(8), Err(AsiCameraError::InvalidSize)); + assert_eq!(check_error_code(9), Err(AsiCameraError::InvalidImgType)); + assert_eq!(check_error_code(10), Err(AsiCameraError::OutOfBoundary)); + assert_eq!(check_error_code(11), Err(AsiCameraError::Timeout)); + assert_eq!(check_error_code(12), Err(AsiCameraError::InvalidSequence)); + assert_eq!(check_error_code(13), Err(AsiCameraError::BufferTooSmall)); + assert_eq!(check_error_code(14), Err(AsiCameraError::VideoModeActive)); + assert_eq!(check_error_code(15), Err(AsiCameraError::ExposureInProgress)); + assert_eq!(check_error_code(16), Err(AsiCameraError::GeneralError)); + assert_eq!(check_error_code(17), Err(AsiCameraError::InvalidMode)); + assert_eq!(check_error_code(18), Err(AsiCameraError::End)); + } + + #[test] + fn unknown_code_wraps_value() { + assert_eq!(check_error_code(99), Err(AsiCameraError::Unknown(99))); + assert_eq!(check_error_code(-5), Err(AsiCameraError::Unknown(-5))); + } } diff --git a/libasi/src/efw.rs b/libasi/src/efw.rs index 4e35a42..5a9527c 100644 --- a/libasi/src/efw.rs +++ b/libasi/src/efw.rs @@ -1,25 +1,48 @@ pub use libasi_sys::efw::*; -use log::error; pub type EFWInfo = _EFW_INFO; pub type EFWId = _EFW_ID; -fn check_error_code(code: i32) { +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum AsiEfwError { + #[error("invalid index")] + InvalidIndex, + #[error("invalid id")] + InvalidId, + #[error("invalid value")] + InvalidValue, + #[error("filter wheel removed")] + Removed, + #[error("filter wheel is moving")] + Moving, + #[error("error state")] + ErrorState, + #[error("general error")] + GeneralError, + #[error("not supported")] + NotSupported, + #[error("device closed")] + Closed, + #[error("end sentinel")] + End, + #[error("unknown error code: {0}")] + Unknown(i32), +} + +fn check_error_code(code: i32) -> Result<(), AsiEfwError> { match code { - 0 => (), - 1 => error!("EFW_ERROR_INVALID_INDEX"), - 2 => error!("EFW_ERROR_INVALID_ID"), - 3 => error!("EFW_ERROR_INVALID_VALUE"), - // Failed to find the filter wheel, maybe the filter wheel has been removed - 4 => error!("EFW_ERROR_REMOVED"), - // Filter wheel is moving - 5 => error!("EFW_ERROR_MOVING"), - 6 => error!("EFW_ERROR_ERROR_STATE"), - 7 => error!("EFW_ERROR_GENERAL_ERROR"), - 8 => error!("EFW_ERROR_NOT_SUPPORTED"), - 9 => error!("EFW_ERROR_CLOSED"), - -1 => error!("EFW_ERROR_END"), - _ => error!("UNKNOWN_ERROR"), + 0 => Ok(()), + 1 => Err(AsiEfwError::InvalidIndex), + 2 => Err(AsiEfwError::InvalidId), + 3 => Err(AsiEfwError::InvalidValue), + 4 => Err(AsiEfwError::Removed), + 5 => Err(AsiEfwError::Moving), + 6 => Err(AsiEfwError::ErrorState), + 7 => Err(AsiEfwError::GeneralError), + 8 => Err(AsiEfwError::NotSupported), + 9 => Err(AsiEfwError::Closed), + -1 => Err(AsiEfwError::End), + n => Err(AsiEfwError::Unknown(n)), } } @@ -39,76 +62,53 @@ pub fn get_product_ids() -> Vec { pids } -pub fn get_efw_id(index: i32, id: *mut i32) { - check_error_code( - unsafe { libasi_sys::efw::EFWGetID(index, id) } - ); +pub fn get_efw_id(index: i32, id: *mut i32) -> Result<(), AsiEfwError> { + check_error_code(unsafe { libasi_sys::efw::EFWGetID(index, id) }) } -pub fn open_efw(id: i32) { - check_error_code( - unsafe { libasi_sys::efw::EFWOpen(id) } - ); +pub fn open_efw(id: i32) -> Result<(), AsiEfwError> { + check_error_code(unsafe { libasi_sys::efw::EFWOpen(id) }) } pub fn check_wheel_is_moving(id: i32) -> bool { let mut info = EFWInfo::new(); let status = unsafe { libasi_sys::efw::EFWGetProperty(id, &mut info) }; - - match status { - 5 => return true, - _ => return false, - }; + matches!(check_error_code(status), Err(AsiEfwError::Moving)) } - -pub fn get_efw_property(id: i32, info: *mut EFWInfo) { - check_error_code( - unsafe { libasi_sys::efw::EFWGetProperty(id, info) } - ); +pub fn get_efw_property(id: i32, info: *mut EFWInfo) -> Result<(), AsiEfwError> { + check_error_code(unsafe { libasi_sys::efw::EFWGetProperty(id, info) }) } -pub fn get_efw_position(id: i32) -> i32 { +pub fn get_efw_position(id: i32) -> Result { let mut position: i32 = 0; - check_error_code( - unsafe { libasi_sys::efw::EFWGetPosition(id, &mut position) } - ); - // To have users dealing with non 0 indexed values, we simply add always 1 to - // the 0 indexed position returned from the firmware - position + 1 + check_error_code(unsafe { libasi_sys::efw::EFWGetPosition(id, &mut position) })?; + // SDK uses 0-based positions; callers work with 1-based. Return 0 while moving. + Ok(position + 1) } -pub fn set_efw_position(id: i32, position: i32) { - // To have users dealing with non 0 indexed values, we simply subtract always 1 to - // the 0 indexed position wanted by the user - let indexed_0_position = position -1; - check_error_code( - unsafe { libasi_sys::efw::EFWSetPosition(id, indexed_0_position) } - ); +pub fn set_efw_position(id: i32, position: i32) -> Result<(), AsiEfwError> { + // Callers supply 1-based positions; SDK expects 0-based. + let indexed_0_position = position - 1; + check_error_code(unsafe { libasi_sys::efw::EFWSetPosition(id, indexed_0_position) }) } -pub fn set_unidirection(id: i32, flag: bool) { - check_error_code( - unsafe { EFWSetDirection(id, flag) } - ); +pub fn set_unidirection(id: i32, flag: bool) -> Result<(), AsiEfwError> { + check_error_code(unsafe { EFWSetDirection(id, flag) }) } -pub fn is_unidirectional(id: i32) -> bool { +pub fn is_unidirectional(id: i32) -> Result { let mut unid: bool = false; - check_error_code( - unsafe { EFWGetDirection(id, &mut unid) } - ); - unid + check_error_code(unsafe { EFWGetDirection(id, &mut unid) })?; + Ok(unid) } -pub fn calibrate_wheel(id: i32) { - check_error_code( - unsafe { EFWCalibrate(id) } - ); +pub fn calibrate_wheel(id: i32) -> Result<(), AsiEfwError> { + check_error_code(unsafe { EFWCalibrate(id) }) } -pub fn close_efw(id: i32) { - check_error_code(unsafe { EFWClose(id) }); +pub fn close_efw(id: i32) -> Result<(), AsiEfwError> { + check_error_code(unsafe { EFWClose(id) }) } /// Returns the SDK version string, e.g. `"1, 8, 4"`. @@ -123,33 +123,68 @@ pub fn get_sdk_version() -> String { } /// Retrieves the hardware-level firmware error code for the given device. -pub fn get_hw_error_code(id: i32) -> i32 { +pub fn get_hw_error_code(id: i32) -> Result { let mut err_code: i32 = 0; - check_error_code(unsafe { libasi_sys::efw::EFWGetHWErrorCode(id, &mut err_code) }); - err_code + check_error_code(unsafe { libasi_sys::efw::EFWGetHWErrorCode(id, &mut err_code) })?; + Ok(err_code) } /// Retrieves the firmware version as `(major, minor, build)`. -pub fn get_firmware_version(id: i32) -> (u8, u8, u8) { +pub fn get_firmware_version(id: i32) -> Result<(u8, u8, u8), AsiEfwError> { let mut major: u8 = 0; let mut minor: u8 = 0; let mut build: u8 = 0; check_error_code(unsafe { libasi_sys::efw::EFWGetFirmwareVersion(id, &mut major, &mut minor, &mut build) - }); - (major, minor, build) + })?; + Ok((major, minor, build)) } /// Retrieves the serial number of the EFW device. /// Returns `EFW_SN` which is an alias for `EFW_ID` (`[u8; 8]`). /// Note: returns `EFW_ERROR_NOT_SUPPORTED` on older firmware. -pub fn get_serial_number(id: i32) -> EFWId { +pub fn get_serial_number(id: i32) -> Result { let mut sn = EFWId::new(); - check_error_code(unsafe { libasi_sys::efw::EFWGetSerialNumber(id, &mut sn) }); - sn + check_error_code(unsafe { libasi_sys::efw::EFWGetSerialNumber(id, &mut sn) })?; + Ok(sn) } /// Writes an 8-byte alias ID to the EFW device flash. -pub fn set_id(id: i32, alias: EFWId) { - check_error_code(unsafe { libasi_sys::efw::EFWSetID(id, alias) }); +pub fn set_id(id: i32, alias: EFWId) -> Result<(), AsiEfwError> { + check_error_code(unsafe { libasi_sys::efw::EFWSetID(id, alias) }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn success_code_is_ok() { + assert!(check_error_code(0).is_ok()); + } + + #[test] + fn all_known_efw_codes_map_correctly() { + assert_eq!(check_error_code(1), Err(AsiEfwError::InvalidIndex)); + assert_eq!(check_error_code(2), Err(AsiEfwError::InvalidId)); + assert_eq!(check_error_code(3), Err(AsiEfwError::InvalidValue)); + assert_eq!(check_error_code(4), Err(AsiEfwError::Removed)); + assert_eq!(check_error_code(5), Err(AsiEfwError::Moving)); + assert_eq!(check_error_code(6), Err(AsiEfwError::ErrorState)); + assert_eq!(check_error_code(7), Err(AsiEfwError::GeneralError)); + assert_eq!(check_error_code(8), Err(AsiEfwError::NotSupported)); + assert_eq!(check_error_code(9), Err(AsiEfwError::Closed)); + assert_eq!(check_error_code(-1), Err(AsiEfwError::End)); + } + + #[test] + fn moving_code_maps_to_moving_error() { + assert_eq!(check_error_code(5), Err(AsiEfwError::Moving)); + } + + #[test] + fn unknown_code_wraps_value() { + assert_eq!(check_error_code(99), Err(AsiEfwError::Unknown(99))); + assert_eq!(check_error_code(-99), Err(AsiEfwError::Unknown(-99))); + } } diff --git a/src/bin/ccd/ccd.rs b/src/bin/ccd/ccd.rs index 33df107..2be7ce2 100644 --- a/src/bin/ccd/ccd.rs +++ b/src/bin/ccd/ccd.rs @@ -1,6 +1,6 @@ use crate::utils::fetch_control_caps; use crate::utils::get_num_of_controls; -use libasi::camera::AsiCameraInfo; +use libasi::camera::{AsiCameraError, AsiCameraInfo}; use astrotools::properties::{Permission, Prop, Property, RangeProperty}; use log::{debug, error, info}; @@ -21,14 +21,16 @@ pub mod utils { pub mod generics { use crate::utils::asi_id_to_string; - use libasi::camera::{get_cam_id, set_cam_id, AsiID}; + use libasi::camera::{AsiID, get_cam_id, set_cam_id}; use log::{debug, info}; - use rand::distr::Alphanumeric; use rand::RngExt; + use rand::distr::Alphanumeric; pub fn get_camera_id(camera_index: i32) -> String { let mut id: AsiID = AsiID::new(); - get_cam_id(camera_index, &mut id); + if let Err(e) = get_cam_id(camera_index, &mut id) { + log::error!("Failed to get camera ID for index {camera_index}: {e}"); + } // if the AsiID is a bunch of 0, we set a random ID and we dump it to the camera flash // memory. If you are wondering why, the reason is the following; one may want to use multiple @@ -69,15 +71,17 @@ pub mod utils { camera_index, asi_id_to_string(&id.id) ); - set_cam_id(camera_index, id); + if let Err(e) = set_cam_id(camera_index, id) { + log::error!("Failed to set camera ID for index {camera_index}: {e}"); + } } } pub mod capturing { use crate::ccd::Camera; use astrotools::properties::Prop; - use base64::prelude::BASE64_STANDARD; use base64::Engine; + use base64::prelude::BASE64_STANDARD; use libasi::camera::{ download_exposure, exposure_status, set_control_value, start_exposure, }; @@ -122,34 +126,44 @@ pub mod utils { // Set the value of the exposure on the driver #[cfg(unix)] { - set_control_value( + if let Err(e) = set_control_value( idx, libasi::camera::ASI_CONTROL_TYPE_ASI_EXPOSURE as i32, secs_to_micros, libasi::camera::ASI_BOOL_ASI_FALSE as i32, - ); + ) { + error!("Failed to set exposure time: {e}"); + return; + } } #[cfg(windows)] { - set_control_value( + if let Err(e) = set_control_value( idx, libasi::camera::ASI_CONTROL_TYPE_ASI_EXPOSURE as i32, secs_to_micros as i32, 0, - ); + ) { + error!("Failed to set exposure time: {e}"); + return; + } } // Send the command to start the exposure - start_exposure(idx); - exposure_status(idx, &mut status); + if let Err(e) = start_exposure(idx) { + error!("Failed to start exposure: {e}"); + return; + } + let _ = exposure_status(idx, &mut status); let start = SystemTime::now(); // Swapping exposure related properties AKA prepare props to show // informations about ongoing exposure { let mut d = device.write().unwrap(); - // TODO: Fix this unused result - let _ = d.exposure_status + // TODO: Fix this unused result + let _ = d + .exposure_status .update_int(std::borrow::Cow::Borrowed("EXPOSING")); } @@ -157,7 +171,7 @@ pub mod utils { // Loop until the status change while status == 1 { - exposure_status(idx, &mut status); + let _ = exposure_status(idx, &mut status); std::thread::sleep(std::time::Duration::from_millis(50)); } @@ -168,13 +182,19 @@ pub mod utils { info!("Exposure successful"); { let mut d = device.write().unwrap(); - // TODO: Fix this unused result - let _ = d.exposure_status + // TODO: Fix this unused result + let _ = d + .exposure_status .update_int(std::borrow::Cow::Borrowed("SUCCESS")); } info!("downloading"); - download_exposure(idx, image_buffer.as_mut_ptr(), buffer_size.into()); + if let Err(e) = + download_exposure(idx, image_buffer.as_mut_ptr(), buffer_size.into()) + { + error!("Failed to download exposure: {e}"); + return; + } let mut mqttoptions = MqttOptions::new("asi_exposure", "127.0.0.1", 1883); mqttoptions.set_keep_alive(Duration::from_secs(5)); @@ -248,31 +268,6 @@ pub mod utils { } } - pub fn check_error_code(code: i32) { - match code { - 0 => (), //ASI_SUCCESS - 1 => error!("ASI_ERROR_INVALID_INDEX"), //no camera connected or index value out of boundary - 2 => error!("ASI_ERROR_INVALID_ID"), //invalid ID - 3 => error!("ASI_ERROR_INVALID_CONTROL_TYPE"), //invalid control type - 4 => error!("ASI_ERROR_CAMERA_CLOSED"), //camera didn't open - 5 => error!("ASI_ERROR_CAMERA_REMOVED"), //failed to find the camera, maybe the camera has been removed - 6 => error!("ASI_ERROR_INVALID_PATH"), //cannot find the path of the file - 7 => error!("ASI_ERROR_INVALID_FILEFORMAT"), - 8 => error!("ASI_ERROR_INVALID_SIZE"), //wrong video format size - 9 => error!("ASI_ERROR_INVALID_IMGTYPE"), //unsupported image formate - 10 => error!("ASI_ERROR_OUTOF_BOUNDARY"), //the startpos is out of boundary - 11 => error!("ASI_ERROR_TIMEOUT"), //timeout - 12 => error!("ASI_ERROR_INVALID_SEQUENCE"), //stop capture first - 13 => error!("ASI_ERROR_BUFFER_TOO_SMALL"), //buffer size is not big enough - 14 => error!("ASI_ERROR_VIDEO_MODE_ACTIVE"), - 15 => error!("ASI_ERROR_EXPOSURE_IN_PROGRESS"), - 16 => error!("ASI_ERROR_GENERAL_ERROR"), //general error, eg: value is out of valid range - 17 => error!("ASI_ERROR_INVALID_MODE"), //the current mode is wrong - 18 => error!("ASI_ERROR_END"), - e => error!("unknown error {}", e), - } - } - #[cfg(unix)] pub fn bayer_pattern_to_str(n: &u32) -> &'static str { match n { @@ -393,7 +388,10 @@ pub mod utils { for i in 0..num_of_caps { let mut control_caps = AsiControlCaps::new(); - libasi::camera::get_control_caps(cam_idx, i, &mut control_caps); + if let Err(e) = libasi::camera::get_control_caps(cam_idx, i, &mut control_caps) { + log::error!("Failed to get control cap {i} for camera {cam_idx}: {e}"); + continue; + } let cap = AsiProperty { name: crate::utils::asi_name_to_string_i8(&control_caps.Name).to_case(Case::Snake), @@ -415,7 +413,9 @@ pub mod utils { /// This method must be called AFTER the camera is initialized by the SDK pub fn get_num_of_controls(index: i32) -> i32 { let mut num_of_controls = 0; - libasi::camera::get_num_of_controls(index, &mut num_of_controls); + if let Err(e) = libasi::camera::get_num_of_controls(index, &mut num_of_controls) { + log::error!("Failed to get number of controls for camera {index}: {e}"); + } info!("Found: {} controls for camera {}", num_of_controls, index); num_of_controls } @@ -460,20 +460,20 @@ pub struct AsiCamera { lightspeed_id: Property>, // Properties to build logic around exposures exposing: Property, - exposure_status: Property>, - width: Property, - height: Property, + pub exposure_status: Property>, + pub width: Property, + pub height: Property, bin: Property, image_type: Property, } impl AsiCamera { - pub fn new(index: i32) -> Self { + pub fn new(index: i32) -> Result { // From the SDK documentation, in order: // 1) Get count of connected cameras (THIS IS DONE ALREADY as we already called look_for_devices // 2) get camera ID using ASIGetCameraProperty let mut info = AsiCameraInfo::new(); - libasi::camera::get_camera_info(&mut info, index); + libasi::camera::get_camera_info(&mut info, index)?; debug!( "Saying welcome to camera `{}`", @@ -481,10 +481,10 @@ impl AsiCamera { ); // 3) Open camera using ASIOpenCamera - libasi::camera::open_camera(index); + libasi::camera::open_camera(index)?; // 4)Initialise the camera using ASIInitCamera - libasi::camera::init_camera(index); + libasi::camera::init_camera(index)?; // 5) Get count of control type with ASIGetControlCaps // Check how many capabilities this camera has, reallocate the vector @@ -551,7 +551,7 @@ impl AsiCamera { device.asi_caps_to_lightspeed_props(); device.fetch_roi_format(); - device + Ok(device) } pub fn fetch_props(&mut self) { @@ -563,8 +563,8 @@ impl AsiCamera { debug!("Cap {} value is {}", &cap.name, &val); let v = self.controls.get_mut(&cap.name).unwrap(); if v.value() != &val { - // TODO: Fix this unused error - let _ = v.update_int(val); + // TODO: Fix this unused error + let _ = v.update_int(val); } } @@ -618,12 +618,14 @@ impl AsiCamera { let mut is_auto_set = 0; let mut val: i64 = 0; - libasi::camera::get_control_value( + if let Err(e) = libasi::camera::get_control_value( *self.index(), cap.control_type, &mut val, &mut is_auto_set, - ); + ) { + error!("Failed to get control value for '{}': {e}", cap.name); + } debug!( "Value for {} is {} - Auto adjusted? {}", cap.name, val, cap.is_writable @@ -634,7 +636,7 @@ impl AsiCamera { /// Close gently the connection to the camera using the SDK pub fn close(&self) { debug!("Closing camera {}", self.name); - libasi::camera::close_camera(*self.index()); + let _ = libasi::camera::close_camera(*self.index()); } fn fetch_roi_format(&mut self) { @@ -644,13 +646,16 @@ impl AsiCamera { let mut bin = 10; let mut img_type = 10; - libasi::camera::get_roi_format( + if let Err(e) = libasi::camera::get_roi_format( *self.index(), &mut width, &mut height, &mut bin, &mut img_type, - ); + ) { + error!("Failed to read ROI format: {e}"); + return; + } // Update now the struct values self.width.update(width).unwrap(); @@ -696,6 +701,8 @@ impl AsiCamera { *self.image_type.value() }; - libasi::camera::set_roi_format(*self.index(), w, h, b, img); + if let Err(e) = libasi::camera::set_roi_format(*self.index(), w, h, b, img) { + error!("Failed to set ROI format: {e}"); + } } } diff --git a/src/bin/ccd/main.rs b/src/bin/ccd/main.rs index 630e1c1..f2804ea 100644 --- a/src/bin/ccd/main.rs +++ b/src/bin/ccd/main.rs @@ -9,8 +9,8 @@ use tokio::task; use uuid::Uuid; pub mod ccd; -use ccd::utils; use ccd::AsiCamera; +use ccd::utils; use std::time::Instant; use rumqttc::Event::{Incoming, Outgoing}; @@ -24,13 +24,14 @@ struct AsiCcd { impl AsiCcd { fn new() -> Self { let found = utils::look_for_devices(); - let mut devices: Vec>> = Vec::with_capacity(found as usize); - - for idx in 0..found { - let device = Arc::new(RwLock::new(AsiCamera::new(idx))); - devices.push(device) - } - + let devices = (0..found) + .filter_map(|idx| { + AsiCamera::new(idx) + .map_err(|e| log::error!("Failed to initialize camera {idx}: {e}")) + .ok() + }) + .map(|d| Arc::new(RwLock::new(d))) + .collect(); Self { devices } } } diff --git a/src/bin/efw/efw.rs b/src/bin/efw/efw.rs index 5ae4b32..5d7a917 100644 --- a/src/bin/efw/efw.rs +++ b/src/bin/efw/efw.rs @@ -1,7 +1,9 @@ -use log::{debug, info, warn}; +use log::{debug, error, info, warn}; use serde::Serialize; use uuid::Uuid; +use libasi::efw::AsiEfwError; + pub fn look_for_devices() -> i32 { let num = libasi::efw::get_num_of_connected_devices(); match num { @@ -25,26 +27,26 @@ pub struct EfwDevice { } impl EfwDevice { - pub fn new(index: i32) -> Self { + pub fn new(index: i32) -> Result { let mut efw_id: i32 = 0; - libasi::efw::get_efw_id(index, &mut efw_id); + libasi::efw::get_efw_id(index, &mut efw_id)?; - libasi::efw::open_efw(efw_id); + libasi::efw::open_efw(efw_id)?; let mut info = libasi::efw::EFWInfo::new(); - libasi::efw::get_efw_property(efw_id, &mut info); + libasi::efw::get_efw_property(efw_id, &mut info)?; let name = asi_rs::utils::asi_name_to_string(&info.Name); let slot_num = info.slotNum; - let current_slot = libasi::efw::get_efw_position(efw_id); - let unidirectional = libasi::efw::is_unidirectional(efw_id); + let current_slot = libasi::efw::get_efw_position(efw_id)?; + let unidirectional = libasi::efw::is_unidirectional(efw_id)?; info!( "EFW '{}' opened: {} slots, current={}, unidirectional={}", name, slot_num, current_slot, unidirectional ); - Self { + Ok(Self { id: Uuid::new_v4(), name: format!("ZWO {}", name), efw_id, @@ -52,7 +54,7 @@ impl EfwDevice { current_slot, unidirectional, calibrating: false, - } + }) } pub fn fetch_props(&mut self) { @@ -60,25 +62,37 @@ impl EfwDevice { if self.calibrating { return; } - let slot = libasi::efw::get_efw_position(self.efw_id); - if self.current_slot != slot { - debug!("Slot changed: {} -> {}", self.current_slot, slot); - self.current_slot = slot; + match libasi::efw::get_efw_position(self.efw_id) { + Ok(slot) => { + if self.current_slot != slot { + debug!("Slot changed: {} -> {}", self.current_slot, slot); + self.current_slot = slot; + } + } + Err(e) => error!("Failed to get EFW position: {e}"), } - let unid = libasi::efw::is_unidirectional(self.efw_id); - if self.unidirectional != unid { - self.unidirectional = unid; + match libasi::efw::is_unidirectional(self.efw_id) { + Ok(unid) => { + if self.unidirectional != unid { + self.unidirectional = unid; + } + } + Err(e) => error!("Failed to get EFW direction: {e}"), } } pub fn set_slot(&self, position: i32) { debug!("Setting EFW slot to {}", position); - libasi::efw::set_efw_position(self.efw_id, position); + if let Err(e) = libasi::efw::set_efw_position(self.efw_id, position) { + error!("Failed to set EFW slot {position}: {e}"); + } } pub fn set_unidirectional(&self, flag: bool) { debug!("Setting EFW unidirectional to {}", flag); - libasi::efw::set_unidirection(self.efw_id, flag); + if let Err(e) = libasi::efw::set_unidirection(self.efw_id, flag) { + error!("Failed to set EFW unidirectional: {e}"); + } } pub fn efw_id(&self) -> i32 { @@ -87,6 +101,34 @@ impl EfwDevice { pub fn close(&self) { debug!("Closing EFW '{}'", self.name); - libasi::efw::close_efw(self.efw_id); + let _ = libasi::efw::close_efw(self.efw_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn efw_device_skips_internal_fields() { + let device = EfwDevice { + id: Uuid::new_v4(), + name: "ZWO EFW Mini".to_string(), + efw_id: 42, + slot_num: 5, + current_slot: 2, + unidirectional: false, + calibrating: false, + }; + let json = serde_json::to_string(&device).unwrap(); + // Internal fields must not appear in serialized output + assert!(!json.contains("\"id\"")); + assert!(!json.contains("\"efw_id\"")); + // Public fields must be present + assert!(json.contains("\"name\"")); + assert!(json.contains("\"slot_num\"")); + assert!(json.contains("\"current_slot\"")); + assert!(json.contains("\"unidirectional\"")); + assert!(json.contains("\"calibrating\"")); } } diff --git a/src/bin/efw/main.rs b/src/bin/efw/main.rs index 780d758..74cd223 100644 --- a/src/bin/efw/main.rs +++ b/src/bin/efw/main.rs @@ -22,11 +22,14 @@ struct AsiEfwDriver { impl AsiEfwDriver { fn new() -> Self { let found = efw::look_for_devices(); - let mut devices = Vec::with_capacity(found as usize); - for idx in 0..found { - let device = Arc::new(RwLock::new(EfwDevice::new(idx))); - devices.push(device); - } + let devices = (0..found) + .filter_map(|idx| { + EfwDevice::new(idx) + .map_err(|e| log::error!("Failed to initialize EFW device {idx}: {e}")) + .ok() + }) + .map(|d| Arc::new(RwLock::new(d))) + .collect(); Self { devices } } } @@ -130,7 +133,9 @@ async fn main() { task::spawn_blocking(move || { let efw_id = device.read().unwrap().efw_id(); device.write().unwrap().calibrating = true; - libasi::efw::calibrate_wheel(efw_id); + if let Err(e) = libasi::efw::calibrate_wheel(efw_id) { + log::error!("Failed to calibrate EFW {efw_id}: {e}"); + } while libasi::efw::check_wheel_is_moving(efw_id) { std::thread::sleep(Duration::from_millis(100)); } @@ -142,10 +147,7 @@ async fn main() { } "update" => { let payload = String::from_utf8_lossy(&data.payload); - info!( - "Update request for {}: {}", - device_id, payload - ); + info!("Update request for {}: {}", device_id, payload); // TODO: parse and dispatch generic property updates } _ => (), diff --git a/src/bin/test/main.rs b/src/bin/test/main.rs index 7cdec1c..fd2c475 100644 --- a/src/bin/test/main.rs +++ b/src/bin/test/main.rs @@ -5,7 +5,7 @@ fn get_roi(idx: i32) { let mut height = 20; let mut bin = 20; let mut img_type = 20; - libasi::camera::get_roi_format(idx, &mut width, &mut height, &mut bin, &mut img_type); + let _ = libasi::camera::get_roi_format(idx, &mut width, &mut height, &mut bin, &mut img_type); println!( "Width: {}\nHeight: {}\nBin: {}\nType: {}", width, height, bin, img_type @@ -13,8 +13,8 @@ fn get_roi(idx: i32) { } fn expose(idx: i32) -> u32 { - let mut e_val = 0; - libasi::camera::get_control_value( + let mut e_val: i64 = 0; + let _ = libasi::camera::get_control_value( idx, libasi::camera::ASI_CONTROL_TYPE_ASI_EXPOSURE as i32, &mut e_val, @@ -22,13 +22,13 @@ fn expose(idx: i32) -> u32 { ); println!("Exp time: {}", e_val); println!("Exposing"); - libasi::camera::start_exposure(idx); + let _ = libasi::camera::start_exposure(idx); let mut status = 0; - libasi::camera::exposure_status(idx, &mut status); + let _ = libasi::camera::exposure_status(idx, &mut status); while status == 1 { - libasi::camera::exposure_status(idx, &mut status); + let _ = libasi::camera::exposure_status(idx, &mut status); std::thread::sleep(std::time::Duration::from_millis(1)); } println!("Exposure status: {}", &status); @@ -41,18 +41,18 @@ fn main() { for idx in 0..num_of_devs { println!("Probing camera {}", &idx); - libasi::camera::open_camera(idx); - libasi::camera::init_camera(idx); + let _ = libasi::camera::open_camera(idx); + let _ = libasi::camera::init_camera(idx); let mut num_of_controls = 0; - libasi::camera::get_num_of_controls(idx, &mut num_of_controls); + let _ = libasi::camera::get_num_of_controls(idx, &mut num_of_controls); println!("Found: {} controls for camera {}", num_of_controls, idx); let mut caps = Vec::with_capacity(num_of_controls as usize); for c_id in 0..num_of_controls { let mut control_caps = libasi::camera::AsiControlCaps::new(); - libasi::camera::get_control_caps(idx, c_id, &mut control_caps); + let _ = libasi::camera::get_control_caps(idx, c_id, &mut control_caps); caps.push(control_caps); } @@ -64,7 +64,7 @@ fn main() { let mut is_auto_set = 0; let mut val: i64 = 0; - libasi::camera::get_control_value( + let _ = libasi::camera::get_control_value( idx, cap.ControlType as i32, &mut val, @@ -85,11 +85,11 @@ fn main() { let mut start_x = 50; let mut start_y = 50; - libasi::camera::get_start_position(idx, &mut start_x, &mut start_y); + let _ = libasi::camera::get_start_position(idx, &mut start_x, &mut start_y); println!("Start X: {}, Start Y: {}", start_x, start_y); - let mut e_val = 0; - libasi::camera::get_control_value( + let mut e_val: i64 = 0; + let _ = libasi::camera::get_control_value( idx, libasi::camera::ASI_CONTROL_TYPE_ASI_EXPOSURE as i32, &mut e_val, @@ -101,17 +101,17 @@ fn main() { let mut cmode = 100; - libasi::camera::get_camera_mode(idx, &mut cmode); + let _ = libasi::camera::get_camera_mode(idx, &mut cmode); println!("Camera mode: {}", cmode); - libasi::camera::set_control_value( + let _ = libasi::camera::set_control_value( idx, libasi::camera::ASI_CONTROL_TYPE_ASI_EXPOSURE as i32, length, libasi::camera::ASI_BOOL_ASI_FALSE as i32, ); - let mut e_val = 0; - libasi::camera::get_control_value( + let mut e_val: i64 = 0; + let _ = libasi::camera::get_control_value( idx, libasi::camera::ASI_CONTROL_TYPE_ASI_EXPOSURE as i32, &mut e_val, @@ -131,6 +131,6 @@ fn main() { } } - libasi::camera::close_camera(idx); + let _ = libasi::camera::close_camera(idx); } } diff --git a/src/lib.rs b/src/lib.rs index 3de3701..996411c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,3 +36,130 @@ pub mod utils { } } } + +/// Parse an MQTT device topic of the form `devices/{UUID}/{action}`. +/// +/// Returns `(uuid, action)` on success, or `None` if the topic is malformed +/// (too short, missing separators, or invalid UUID). +pub fn parse_device_topic(topic: &str) -> Option<(uuid::Uuid, &str)> { + // "devices/" = 8 chars, UUID = 36 chars, "/" = 1 char → minimum length is 46 + if topic.len() < 46 { + return None; + } + let uuid_str = &topic[8..44]; + let action = &topic[45..]; + let uuid = uuid_str.parse::().ok()?; + Some((uuid, action)) +} + +#[cfg(test)] +mod tests { + use super::*; + use utils::{asi_id_to_string, asi_name_to_string}; + + // --- asi_name_to_string --- + + #[test] + fn name_normal_with_null_terminator() { + let mut arr = [0i8; 64]; + for (i, &b) in b"ASI294MC Pro".iter().enumerate() { + arr[i] = b as i8; + } + assert_eq!(asi_name_to_string(&arr), "ASI294MC Pro"); + } + + #[test] + fn name_all_zeros_returns_empty() { + let arr = [0i8; 64]; + assert_eq!(asi_name_to_string(&arr), ""); + } + + #[test] + fn name_no_null_terminator_uses_full_array() { + let arr: Vec = b"ABCD".iter().map(|&b| b as i8).collect(); + assert_eq!(asi_name_to_string(&arr), "ABCD"); + } + + #[test] + fn name_null_in_middle_truncates() { + let arr: Vec = vec![65, 66, 0, 67, 68]; // "AB\0CD" + assert_eq!(asi_name_to_string(&arr), "AB"); + } + + #[test] + fn name_negative_i8_replaced_with_hash() { + // i8 values that cannot be converted to u8 (< 0) become '#' (0x23) + let arr: Vec = vec![65, -1, 66]; // 'A', invalid, 'B' + let result = asi_name_to_string(&arr); + assert_eq!(result, "A#B"); + } + + // --- asi_id_to_string --- + + #[test] + fn id_normal_with_null_terminator() { + let mut arr = [0u8; 8]; + for (i, &b) in b"MYID1234".iter().enumerate() { + arr[i] = b; + } + assert_eq!(asi_id_to_string(&arr), "MYID1234"); + } + + #[test] + fn id_all_zeros_returns_empty() { + let arr = [0u8; 8]; + assert_eq!(asi_id_to_string(&arr), ""); + } + + #[test] + fn id_null_in_middle_truncates() { + let arr: Vec = vec![65, 66, 0, 67, 68]; // "AB\0CD" + assert_eq!(asi_id_to_string(&arr), "AB"); + } + + #[test] + fn id_no_null_uses_full_array() { + let arr: Vec = b"ABCD".to_vec(); + assert_eq!(asi_id_to_string(&arr), "ABCD"); + } + + // --- parse_device_topic --- + + #[test] + fn parse_valid_expose_topic() { + let uuid = uuid::Uuid::new_v4(); + let topic = format!("devices/{}/expose", uuid); + let (parsed_uuid, action) = parse_device_topic(&topic).expect("should parse"); + assert_eq!(parsed_uuid, uuid); + assert_eq!(action, "expose"); + } + + #[test] + fn parse_valid_set_slot_topic() { + let uuid = uuid::Uuid::new_v4(); + let topic = format!("devices/{}/set_slot", uuid); + let (parsed_uuid, action) = parse_device_topic(&topic).expect("should parse"); + assert_eq!(parsed_uuid, uuid); + assert_eq!(action, "set_slot"); + } + + #[test] + fn parse_valid_calibrate_topic() { + let uuid = uuid::Uuid::new_v4(); + let topic = format!("devices/{}/calibrate", uuid); + let (_, action) = parse_device_topic(&topic).expect("should parse"); + assert_eq!(action, "calibrate"); + } + + #[test] + fn parse_rejects_topic_too_short() { + assert!(parse_device_topic("devices/short").is_none()); + assert!(parse_device_topic("").is_none()); + } + + #[test] + fn parse_rejects_invalid_uuid() { + let topic = "devices/not-a-valid-uuid-at-all-here/expose"; + assert!(parse_device_topic(topic).is_none()); + } +}