use crate::error::{Error, Result};
use crate::gimbal::{Attitude, GimbalDevice};
use parking_lot::Mutex;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
use tokio::task;
const CENTI_DEGREE_SCALE: f32 = 100.0;
#[derive(Clone)]
pub struct GimbalHandle {
inner: Arc<Mutex<Box<dyn GimbalDevice>>>,
yaw_offset_centi_deg: Arc<AtomicI32>,
}
impl GimbalHandle {
pub fn new(device: Box<dyn GimbalDevice>) -> Self {
Self {
inner: Arc::new(Mutex::new(device)),
yaw_offset_centi_deg: Arc::new(AtomicI32::new(0)),
}
}
pub fn with_yaw_offset_deg(device: Box<dyn GimbalDevice>, offset_deg: f32) -> Self {
let h = Self::new(device);
h.set_yaw_offset_deg(offset_deg);
h
}
pub fn set_yaw_offset_deg(&self, offset_deg: f32) {
let centi = if offset_deg.is_finite() {
(offset_deg * CENTI_DEGREE_SCALE).round() as i32
} else {
0
};
self.yaw_offset_centi_deg.store(centi, Ordering::Relaxed);
}
pub fn yaw_offset_deg(&self) -> f32 {
self.yaw_offset_centi_deg.load(Ordering::Relaxed) as f32 / CENTI_DEGREE_SCALE
}
pub async fn set_attitude(&self, pitch: f32, roll: f32, yaw: f32) -> Result<()> {
let inner = self.inner.clone();
task::spawn_blocking(move || inner.lock().set_attitude(pitch, roll, yaw))
.await
.map_err(join_err)?
}
pub async fn get_attitude(&self) -> Result<Attitude> {
let inner = self.inner.clone();
let raw = task::spawn_blocking(move || inner.lock().get_attitude())
.await
.map_err(join_err)??;
Ok(Attitude {
yaw: raw.yaw - self.yaw_offset_deg(),
..raw
})
}
pub async fn get_attitude_raw(&self) -> Result<Attitude> {
let inner = self.inner.clone();
task::spawn_blocking(move || inner.lock().get_attitude())
.await
.map_err(join_err)?
}
pub async fn set_pan_mode(&self, mode: u8) -> Result<()> {
let inner = self.inner.clone();
task::spawn_blocking(move || inner.lock().set_pan_mode(mode))
.await
.map_err(join_err)?
}
pub async fn set_standby(&self, enabled: bool) -> Result<()> {
let inner = self.inner.clone();
task::spawn_blocking(move || inner.lock().set_standby(enabled))
.await
.map_err(join_err)?
}
pub async fn get_version(&self) -> Result<String> {
let inner = self.inner.clone();
task::spawn_blocking(move || inner.lock().get_version())
.await
.map_err(join_err)?
}
pub async fn protocol_name(&self) -> &'static str {
let inner = self.inner.clone();
task::spawn_blocking(move || inner.lock().protocol_name())
.await
.unwrap_or("unknown")
}
pub async fn replace(&self, new_device: Box<dyn GimbalDevice>) {
let inner = self.inner.clone();
if let Err(e) = task::spawn_blocking(move || {
*inner.lock() = new_device;
})
.await
{
tracing::warn!("Gimbal swap task panicked: {e}");
}
}
}
fn join_err(e: task::JoinError) -> Error {
Error::Daemon(format!("gimbal blocking task: {e}"))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::error::Result as TurretResult;
struct MockGimbal {
last_set: Arc<Mutex<Option<(f32, f32, f32)>>>,
next_get: Arc<Mutex<Attitude>>,
}
impl GimbalDevice for MockGimbal {
fn protocol_name(&self) -> &'static str {
"mock"
}
fn set_attitude(&mut self, pitch: f32, roll: f32, yaw: f32) -> TurretResult<()> {
*self.last_set.lock() = Some((pitch, roll, yaw));
Ok(())
}
fn get_attitude(&mut self) -> TurretResult<Attitude> {
Ok(*self.next_get.lock())
}
}
struct MockHandles {
handle: GimbalHandle,
last_set: Arc<Mutex<Option<(f32, f32, f32)>>>,
next_get: Arc<Mutex<Attitude>>,
}
fn mock_handles() -> MockHandles {
let last_set = Arc::new(Mutex::new(None));
let next_get = Arc::new(Mutex::new(Attitude {
pitch: 0.0,
roll: 0.0,
yaw: 0.0,
}));
let dev = Box::new(MockGimbal {
last_set: last_set.clone(),
next_get: next_get.clone(),
});
MockHandles {
handle: GimbalHandle::new(dev),
last_set,
next_get,
}
}
#[tokio::test]
async fn set_attitude_passes_yaw_through_unchanged() {
let mh = mock_handles();
mh.handle.set_yaw_offset_deg(-4.5);
mh.handle.set_attitude(0.0, 0.0, 0.0).await.unwrap();
let (p, r, y) = mh.last_set.lock().expect("set_attitude was called");
assert_eq!(p, 0.0);
assert_eq!(r, 0.0);
assert_eq!(y, 0.0, "device yaw should equal operator yaw");
mh.handle.set_attitude(0.0, 0.0, 30.0).await.unwrap();
let (_, _, y) = mh.last_set.lock().expect("set_attitude was called");
assert_eq!(y, 30.0, "device yaw should equal operator yaw");
}
#[tokio::test]
async fn get_attitude_subtracts_yaw_offset_from_device_read() {
let mh = mock_handles();
mh.handle.set_yaw_offset_deg(-4.5);
*mh.next_get.lock() = Attitude {
pitch: 0.0,
roll: 0.0,
yaw: -4.5,
};
let att = mh.handle.get_attitude().await.unwrap();
assert_eq!(att.pitch, 0.0);
assert_eq!(att.roll, 0.0);
assert!(
att.yaw.abs() < 1e-4,
"operator-frame yaw should be ~0, got {}",
att.yaw
);
*mh.next_get.lock() = Attitude {
pitch: 0.0,
roll: 0.0,
yaw: 15.5,
};
let att = mh.handle.get_attitude().await.unwrap();
assert!(
(att.yaw - 20.0).abs() < 1e-4,
"operator-frame yaw should be 20, got {}",
att.yaw
);
}
#[tokio::test]
async fn pitch_and_roll_are_passthrough() {
let mh = mock_handles();
mh.handle.set_yaw_offset_deg(7.0);
mh.handle.set_attitude(12.0, -3.0, 0.0).await.unwrap();
let (p, r, _) = mh.last_set.lock().unwrap();
assert_eq!(p, 12.0);
assert_eq!(r, -3.0);
*mh.next_get.lock() = Attitude {
pitch: 42.0,
roll: -8.0,
yaw: 7.0, };
let att = mh.handle.get_attitude().await.unwrap();
assert_eq!(att.pitch, 42.0);
assert_eq!(att.roll, -8.0);
assert!(att.yaw.abs() < 1e-4);
}
#[tokio::test]
async fn get_attitude_raw_skips_offset() {
let mh = mock_handles();
mh.handle.set_yaw_offset_deg(-4.5);
*mh.next_get.lock() = Attitude {
pitch: 0.0,
roll: 0.0,
yaw: -4.5,
};
let raw = mh.handle.get_attitude_raw().await.unwrap();
assert!(
(raw.yaw - -4.5).abs() < 1e-4,
"raw yaw should be -4.5, got {}",
raw.yaw
);
}
#[test]
fn non_finite_offset_is_clamped_to_zero() {
let mh = mock_handles();
mh.handle.set_yaw_offset_deg(f32::NAN);
assert_eq!(mh.handle.yaw_offset_deg(), 0.0);
mh.handle.set_yaw_offset_deg(f32::INFINITY);
assert_eq!(mh.handle.yaw_offset_deg(), 0.0);
mh.handle.set_yaw_offset_deg(f32::NEG_INFINITY);
assert_eq!(mh.handle.yaw_offset_deg(), 0.0);
mh.handle.set_yaw_offset_deg(-3.25);
assert!((mh.handle.yaw_offset_deg() - -3.25).abs() < 1e-4);
}
}