use super::error::{Result, Storm32Error};
use super::frame::{RCMessage, RC_START_SIGN_OUT};
use serialport::SerialPort;
use std::io::{Read, Write};
use std::path::Path;
use std::time::{Duration, Instant};
use tracing::{debug, trace, warn};
#[derive(Debug, Clone, Copy)]
pub struct GimbalAngles {
pub pitch: f32,
pub roll: f32,
pub yaw: f32,
}
#[derive(Debug, Clone, Copy)]
pub struct GimbalStatus {
pub pv: GimbalAngles,
pub sp: GimbalAngles,
}
pub struct Storm32Gimbal {
pub(super) port: Box<dyn SerialPort>,
pub(super) current_status: GimbalStatus,
}
impl Storm32Gimbal {
pub(super) fn validate_device_path(device_path: &str) -> Result<()> {
if device_path.contains("..") {
return Err(Storm32Error::ValidationError(
"Invalid device path: path traversal detected".to_string(),
));
}
let path = Path::new(device_path);
if !path.exists() {
return Err(Storm32Error::DeviceError(format!(
"Device path does not exist: {}",
device_path
)));
}
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
let metadata = std::fs::metadata(path)?;
let file_type = metadata.file_type();
if !file_type.is_char_device() {
return Err(Storm32Error::DeviceError(format!(
"Path is not a character device: {}",
device_path
)));
}
}
Ok(())
}
pub fn new(device_path: &str) -> Result<Self> {
Self::validate_device_path(device_path)?;
let port = serialport::new(device_path, 115200)
.timeout(Duration::from_millis(1000))
.open()?;
Ok(Self {
port,
current_status: GimbalStatus {
pv: GimbalAngles {
pitch: 0.0,
roll: 0.0,
yaw: 0.0,
},
sp: GimbalAngles {
pitch: 0.0,
roll: 0.0,
yaw: 0.0,
},
},
})
}
pub fn get_status(&self) -> GimbalStatus {
self.current_status
}
#[tracing::instrument(level = "trace", skip(self, message), fields(cmd = ?message.command))]
pub(super) fn send_rc_command(&mut self, message: &RCMessage) -> Result<Vec<u8>> {
self.port.clear(serialport::ClearBuffer::Input)?;
let data = message.serialize();
trace!("Sending RC command: {:02X?}", data);
self.port.write_all(&data)?;
self.port.flush()?;
std::thread::sleep(Duration::from_millis(5));
const READ_BUDGET: Duration = Duration::from_millis(200);
const MAX_GARBAGE_RESTARTS: u32 = 3;
const ACK_BYTE: u8 = 0x4A;
let deadline = Instant::now() + READ_BUDGET;
let mut buffer = [0u8; 256];
let mut total_bytes: usize = 0;
let mut garbage_restarts: u32 = 0;
loop {
if Instant::now() >= deadline {
break;
}
match self.port.read(&mut buffer[total_bytes..]) {
Ok(0) => continue,
Ok(bytes_read) => {
total_bytes += bytes_read;
if buffer[0] != RC_START_SIGN_OUT && buffer[0] != ACK_BYTE {
if garbage_restarts >= MAX_GARBAGE_RESTARTS {
return Err(Storm32Error::ProtocolError(format!(
"Garbage on serial line (start byte 0x{:02X}); \
gave up after {} restart attempts",
buffer[0], MAX_GARBAGE_RESTARTS
)));
}
debug!(
"Garbage start byte 0x{:02X}, restart {}/{}",
buffer[0],
garbage_restarts + 1,
MAX_GARBAGE_RESTARTS
);
garbage_restarts += 1;
self.port.clear(serialport::ClearBuffer::Input)?;
total_bytes = 0;
continue;
}
if buffer[0] == ACK_BYTE {
break;
}
if total_bytes < 2 {
continue;
}
let expected_total = 5 + buffer[1] as usize;
if expected_total > buffer.len() {
return Err(Storm32Error::ProtocolError(format!(
"LEN byte {} would overrun read buffer ({} bytes)",
buffer[1],
buffer.len()
)));
}
if total_bytes >= expected_total {
break;
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {
continue;
}
Err(e) => return Err(Storm32Error::IoError(e)),
}
}
if total_bytes == 0 {
return Err(Storm32Error::ProtocolError(
"No response received".to_string(),
));
}
if buffer[0] == RC_START_SIGN_OUT && total_bytes >= 2 {
let expected_total = 5 + buffer[1] as usize;
if total_bytes < expected_total {
warn!(
"Read deadline expired with partial frame: got {} bytes, expected {}",
total_bytes, expected_total
);
}
}
trace!("Received RC response: {:02X?}", &buffer[..total_bytes]);
RCMessage::parse_response(&buffer[..total_bytes])
}
pub(super) fn validate_angle(angle: f32, name: &str) -> Result<()> {
if !angle.is_finite() {
return Err(Storm32Error::ValidationError(format!(
"{} must be a finite number",
name
)));
}
if !(-180.0..=180.0).contains(&angle) {
return Err(Storm32Error::ValidationError(format!(
"{} must be between -180° and +180° (got {:.1}°)",
name, angle
)));
}
Ok(())
}
pub(super) fn validate_rc_value(value: u16, name: &str) -> Result<()> {
if value != 0 && !(700..=2300).contains(&value) {
return Err(Storm32Error::ValidationError(format!(
"{} must be 0 (recenter) or 700-2300 (got {})",
name, value
)));
}
Ok(())
}
pub(super) fn validate_pan_mode(mode: u8) -> Result<()> {
if mode > 5 {
return Err(Storm32Error::ValidationError(format!(
"Pan mode must be 0-5 (got {})",
mode
)));
}
Ok(())
}
}
pub type Storm32RC = Storm32Gimbal;
impl Storm32RC {
pub fn try_detect(device_path: &str) -> crate::error::Result<Self> {
Self::validate_device_path(device_path)?;
let mut gimbal = Self::new(device_path)?;
match gimbal.get_version() {
Ok(version) => {
tracing::info!("Detected STorM32 RC device: {}", version);
Ok(gimbal)
}
Err(e) => {
tracing::debug!("STorM32 RC detection failed: {}", e);
Err(e.into())
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn test_angle_validation() {
assert!(Storm32Gimbal::validate_angle(0.0, "Test").is_ok());
assert!(Storm32Gimbal::validate_angle(180.0, "Test").is_ok());
assert!(Storm32Gimbal::validate_angle(-180.0, "Test").is_ok());
assert!(Storm32Gimbal::validate_angle(45.5, "Test").is_ok());
assert!(Storm32Gimbal::validate_angle(181.0, "Test").is_err());
assert!(Storm32Gimbal::validate_angle(-181.0, "Test").is_err());
assert!(Storm32Gimbal::validate_angle(f32::NAN, "Test").is_err());
assert!(Storm32Gimbal::validate_angle(f32::INFINITY, "Test").is_err());
}
#[test]
fn test_rc_value_validation() {
assert!(Storm32Gimbal::validate_rc_value(0, "Test").is_ok());
assert!(Storm32Gimbal::validate_rc_value(700, "Test").is_ok());
assert!(Storm32Gimbal::validate_rc_value(1500, "Test").is_ok());
assert!(Storm32Gimbal::validate_rc_value(2300, "Test").is_ok());
assert!(Storm32Gimbal::validate_rc_value(699, "Test").is_err());
assert!(Storm32Gimbal::validate_rc_value(2301, "Test").is_err());
assert!(Storm32Gimbal::validate_rc_value(500, "Test").is_err());
}
#[test]
fn test_pan_mode_validation() {
for mode in 0..=5 {
assert!(Storm32Gimbal::validate_pan_mode(mode).is_ok());
}
assert!(Storm32Gimbal::validate_pan_mode(6).is_err());
assert!(Storm32Gimbal::validate_pan_mode(255).is_err());
}
#[test]
fn test_device_path_validation() {
assert!(Storm32Gimbal::validate_device_path("../etc/passwd").is_err());
assert!(Storm32Gimbal::validate_device_path("/dev/../etc/passwd").is_err());
assert!(Storm32Gimbal::validate_device_path("/dev/nonexistent123456").is_err());
}
}