use crate::daemon::calibration::Calibration;
use crate::daemon::gimbal_handle::GimbalHandle;
use crate::daemon::state::StateManager;
use crate::daemon::yaw_corrector::YawCorrector;
use crate::gimbal::Attitude;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{broadcast, Notify};
use tokio::time;
use tracing::{info, warn};
#[derive(Debug, Clone, Copy)]
pub struct AttitudeTick {
pub attitude: Attitude,
pub link_recently_failed: bool,
}
const ATTITUDE_CHANNEL_CAPACITY: usize = 16;
const POLL_INTERVAL: Duration = Duration::from_millis(250);
const FAILURE_THRESHOLD: u32 = 8;
const COMMS_ERROR_LINGER: Duration = Duration::from_secs(1);
const PERSIST_THRESHOLD_DEG: f32 = 0.05;
const PERSIST_INTERVAL: Duration = Duration::from_secs(60);
pub fn attitude_channel() -> (
broadcast::Sender<AttitudeTick>,
broadcast::Receiver<AttitudeTick>,
) {
broadcast::channel(ATTITUDE_CHANNEL_CAPACITY)
}
pub async fn attitude_poll_loop(
gimbal: GimbalHandle,
state_manager: StateManager,
notify_lost: Arc<Notify>,
corrector: Option<YawCorrector>,
tx: broadcast::Sender<AttitudeTick>,
) {
let mut interval = time::interval(POLL_INTERVAL);
interval.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
interval.tick().await;
let mut consecutive_failures: u32 = 0;
let mut last_failure: Option<Instant> = None;
let mut last_persisted_offset: f32 = gimbal.yaw_offset_deg();
let mut last_persist_at: Option<Instant> = None;
loop {
interval.tick().await;
let attitude = match gimbal.get_attitude().await {
Ok(a) => {
if consecutive_failures > 0 {
info!(
"Attitude readback recovered after {} consecutive failures",
consecutive_failures
);
}
consecutive_failures = 0;
a
}
Err(e) => {
consecutive_failures = consecutive_failures.wrapping_add(1);
last_failure = Some(Instant::now());
if consecutive_failures == 1 || consecutive_failures % 20 == 0 {
warn!(
"Attitude readback failed (#{}): {}",
consecutive_failures, e
);
}
if consecutive_failures == FAILURE_THRESHOLD {
warn!(
"Gimbal silent for {} consecutive polls; signaling reconnect",
FAILURE_THRESHOLD
);
notify_lost.notify_one();
}
continue;
}
};
state_manager.update_measured_angles(attitude.yaw, attitude.pitch, attitude.roll);
let last_command = state_manager.get_last_command();
let last_sp_yaw = last_command.as_ref().and_then(|c| c.yaw);
let time_since_set = last_command
.as_ref()
.and_then(|c| c.timestamp.elapsed().ok());
if let Some(step) =
corrector.and_then(|c| c.step(attitude.yaw, last_sp_yaw, time_since_set))
{
let new_offset = gimbal.yaw_offset_deg() + step.correction_deg;
gimbal.set_yaw_offset_deg(new_offset);
if (new_offset - last_persisted_offset).abs() > PERSIST_THRESHOLD_DEG
&& last_persist_at
.map(|t| t.elapsed() >= PERSIST_INTERVAL)
.unwrap_or(true)
{
let cal = Calibration {
yaw_offset_deg: new_offset,
};
match cal.save() {
Ok(()) => {
info!(
"Yaw corrector: persisted offset {:.3}° (was {:.3}°, error {:.3}°)",
new_offset, last_persisted_offset, step.error_deg
);
last_persisted_offset = new_offset;
last_persist_at = Some(Instant::now());
}
Err(e) => {
warn!(
"Yaw corrector: persist failed (in-memory offset still applied): {}",
e
);
}
}
}
}
let link_recently_failed = matches!(
last_failure,
Some(t) if t.elapsed() < COMMS_ERROR_LINGER
);
let tick = AttitudeTick {
attitude,
link_recently_failed,
};
match tx.send(tick) {
Ok(_) | Err(_) => {}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn attitude_channel_publishes_to_multiple_subscribers() {
let (tx, rx_a) = attitude_channel();
let mut rx_a = rx_a;
let mut rx_b = tx.subscribe();
let tick_one = AttitudeTick {
attitude: Attitude {
pitch: 1.0,
roll: 2.0,
yaw: 3.0,
},
link_recently_failed: false,
};
tx.send(tick_one).unwrap();
let received_a = rx_a.try_recv().unwrap();
let received_b = rx_b.try_recv().unwrap();
assert_eq!(received_a.attitude.pitch, 1.0);
assert_eq!(received_b.attitude.pitch, 1.0);
let mut rx_c = tx.subscribe();
assert!(rx_c.try_recv().is_err());
let tick_two = AttitudeTick {
attitude: Attitude {
pitch: 4.0,
roll: 5.0,
yaw: 6.0,
},
link_recently_failed: true,
};
tx.send(tick_two).unwrap();
assert_eq!(rx_a.try_recv().unwrap().attitude.pitch, 4.0);
assert_eq!(rx_b.try_recv().unwrap().attitude.pitch, 4.0);
let third = rx_c.try_recv().unwrap();
assert_eq!(third.attitude.pitch, 4.0);
assert!(third.link_recently_failed);
}
#[test]
fn attitude_channel_send_with_no_subscribers_does_not_block_or_error_silently() {
let (tx, rx) = attitude_channel();
drop(rx);
let tick = AttitudeTick {
attitude: Attitude {
pitch: 0.0,
roll: 0.0,
yaw: 0.0,
},
link_recently_failed: false,
};
assert!(tx.send(tick).is_err());
}
}