use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use crate::coordinates::{Coordinates, EquatorialPosition, HorizontalPosition, TrackingRates};
use crate::error::{MountError, MountResult};
use crate::mount::{Mount, MountStatus, SimulatedMount, SiteLocation};
use crate::protocol::{Direction, MountMode, SlewRate, TrackingRate};
#[derive(Debug, Clone)]
struct MockState {
connected: bool,
ra: f64,
dec: f64,
target_ra: f64,
target_dec: f64,
is_slewing: bool,
slew_start_time: Option<Instant>,
slew_duration: Duration,
moving_north: bool,
moving_south: bool,
moving_east: bool,
moving_west: bool,
is_tracking: bool,
tracking_rate: TrackingRate,
custom_ra_rate: f64, custom_dec_rate: f64,
mount_mode: MountMode,
slew_rate: SlewRate,
guide_rate: f64,
is_parked: bool,
site_location: SiteLocation,
last_update: Instant,
time_multiplier: f64,
simulated_time: DateTime<Utc>,
}
impl Default for MockState {
fn default() -> Self {
Self {
connected: false,
ra: 0.0,
dec: 0.0,
target_ra: 0.0,
target_dec: 0.0,
is_slewing: false,
slew_start_time: None,
slew_duration: Duration::from_secs(0),
moving_north: false,
moving_south: false,
moving_east: false,
moving_west: false,
is_tracking: false,
tracking_rate: TrackingRate::Sidereal,
custom_ra_rate: Coordinates::SIDEREAL_RATE_ARCSEC_PER_SEC,
custom_dec_rate: 0.0,
mount_mode: MountMode::Equatorial,
slew_rate: SlewRate::FIND,
guide_rate: 0.5,
is_parked: true,
site_location: SiteLocation::default(),
last_update: Instant::now(),
time_multiplier: 1.0,
simulated_time: Utc::now(),
}
}
}
pub struct MockMount {
state: Arc<Mutex<MockState>>,
pub max_slew_speed: f64,
pub firmware_version: String,
pub model_name: String,
}
impl Default for MockMount {
fn default() -> Self {
Self::new()
}
}
impl MockMount {
pub fn new() -> Self {
Self {
state: Arc::new(Mutex::new(MockState::default())),
max_slew_speed: 6.0, firmware_version: "1.0.0-mock".to_string(),
model_name: "ZWO AM5 (Mock)".to_string(),
}
}
pub fn with_position(ra_hours: f64, dec_degrees: f64) -> Self {
let mount = Self::new();
{
let mut state = mount.state.lock().unwrap();
state.ra = Coordinates::normalize_ra(ra_hours);
state.dec = Coordinates::normalize_dec(dec_degrees);
}
mount
}
pub fn with_location(latitude: f64, longitude: f64, altitude: f64) -> Self {
let mount = Self::new();
{
let mut state = mount.state.lock().unwrap();
state.site_location = SiteLocation::new(latitude, longitude, altitude);
}
mount
}
fn update_state(&self) {
let mut state = self.state.lock().unwrap();
let now = Instant::now();
let elapsed = now.duration_since(state.last_update);
let dt_seconds = elapsed.as_secs_f64() * state.time_multiplier;
state.last_update = now;
let simulated_elapsed = chrono::Duration::milliseconds((dt_seconds * 1000.0) as i64);
state.simulated_time = state.simulated_time + simulated_elapsed;
if state.is_slewing {
if let Some(start_time) = state.slew_start_time {
let slew_elapsed = now.duration_since(start_time);
if slew_elapsed >= state.slew_duration {
state.ra = state.target_ra;
state.dec = state.target_dec;
state.is_slewing = false;
state.slew_start_time = None;
} else {
let progress = slew_elapsed.as_secs_f64() / state.slew_duration.as_secs_f64();
let start_ra = state.ra;
let start_dec = state.dec;
let mut ra_diff = state.target_ra - start_ra;
if ra_diff > 12.0 {
ra_diff -= 24.0;
} else if ra_diff < -12.0 {
ra_diff += 24.0;
}
state.ra = Coordinates::normalize_ra(start_ra + ra_diff * progress);
state.dec = start_dec + (state.target_dec - start_dec) * progress;
}
}
}
if !state.is_slewing && !state.is_parked {
let rate_deg_per_sec = Self::slew_rate_to_deg_per_sec(state.slew_rate);
let movement = rate_deg_per_sec * dt_seconds;
if state.moving_north {
state.dec = (state.dec + movement).min(90.0);
}
if state.moving_south {
state.dec = (state.dec - movement).max(-90.0);
}
if state.moving_east {
state.ra = Coordinates::normalize_ra(state.ra - movement / 15.0);
}
if state.moving_west {
state.ra = Coordinates::normalize_ra(state.ra + movement / 15.0);
}
}
if state.is_tracking && !state.is_slewing && !state.is_parked {
let ra_rate_arcsec = match state.tracking_rate {
TrackingRate::Sidereal => Coordinates::SIDEREAL_RATE_ARCSEC_PER_SEC,
TrackingRate::Lunar => 14.685, TrackingRate::Solar => 15.0, TrackingRate::Off => state.custom_ra_rate, };
let ra_rate_hours_per_sec = ra_rate_arcsec / 3600.0 / 15.0;
state.ra = Coordinates::normalize_ra(state.ra + ra_rate_hours_per_sec * dt_seconds);
if state.tracking_rate == TrackingRate::Off || state.custom_dec_rate.abs() > 0.0 {
let dec_rate_deg_per_sec = state.custom_dec_rate / 3600.0;
state.dec =
Coordinates::normalize_dec(state.dec + dec_rate_deg_per_sec * dt_seconds);
}
}
}
fn slew_rate_to_deg_per_sec(rate: SlewRate) -> f64 {
match rate.value() {
1 => 0.004, 2 => 0.015,
3 => 0.05,
4 => 0.2, 5 => 0.5,
6 => 1.0, 7 => 2.0,
8 => 4.0,
9 => 6.0, _ => 1.0,
}
}
fn calculate_slew_duration(
&self,
from_ra: f64,
from_dec: f64,
to_ra: f64,
to_dec: f64,
) -> Duration {
let ra_diff = {
let mut diff = (to_ra - from_ra).abs();
if diff > 12.0 {
diff = 24.0 - diff;
}
diff * 15.0 };
let dec_diff = (to_dec - from_dec).abs();
let total_distance = (ra_diff * ra_diff + dec_diff * dec_diff).sqrt();
let seconds = total_distance / self.max_slew_speed;
Duration::from_secs_f64(seconds + 0.5)
}
fn ensure_connected(&self) -> MountResult<()> {
let state = self.state.lock().unwrap();
if !state.connected {
Err(MountError::NotConnected)
} else {
Ok(())
}
}
fn ensure_not_parked(&self) -> MountResult<()> {
let state = self.state.lock().unwrap();
if state.is_parked {
Err(MountError::unsupported("Mount is parked"))
} else {
Ok(())
}
}
}
impl Mount for MockMount {
fn connect(&mut self) -> MountResult<()> {
let mut state = self.state.lock().unwrap();
state.connected = true;
state.last_update = Instant::now();
log::info!("Mock mount connected");
Ok(())
}
fn disconnect(&mut self) -> MountResult<()> {
let mut state = self.state.lock().unwrap();
state.connected = false;
state.is_tracking = false;
state.is_slewing = false;
log::info!("Mock mount disconnected");
Ok(())
}
fn is_connected(&self) -> bool {
self.state.lock().unwrap().connected
}
fn get_position(&self) -> MountResult<EquatorialPosition> {
self.ensure_connected()?;
self.update_state();
let state = self.state.lock().unwrap();
Ok(EquatorialPosition::new(state.ra, state.dec))
}
fn get_altaz(&self) -> MountResult<HorizontalPosition> {
self.ensure_connected()?;
self.update_state();
let state = self.state.lock().unwrap();
let jd = {
let dt = state.simulated_time;
let year = dt.format("%Y").to_string().parse::<i32>().unwrap();
let month = dt.format("%m").to_string().parse::<u8>().unwrap();
let day = dt.format("%d").to_string().parse::<f64>().unwrap();
let hour = dt.format("%H").to_string().parse::<f64>().unwrap();
let min = dt.format("%M").to_string().parse::<f64>().unwrap();
let sec = dt.format("%S").to_string().parse::<f64>().unwrap();
let day_frac = day + (hour + min / 60.0 + sec / 3600.0) / 24.0;
Coordinates::to_julian_date(year, month, day_frac)
};
let lst = Coordinates::julian_to_lst(jd, state.site_location.longitude);
let (az, alt) = Coordinates::equatorial_to_horizontal(
state.ra,
state.dec,
state.site_location.latitude,
lst,
);
Ok(HorizontalPosition::new(az, alt))
}
fn goto_equatorial(&mut self, position: EquatorialPosition) -> MountResult<()> {
self.ensure_connected()?;
self.ensure_not_parked()?;
self.update_state();
let mut state = self.state.lock().unwrap();
let duration = self.calculate_slew_duration(state.ra, state.dec, position.ra, position.dec);
state.target_ra = position.ra;
state.target_dec = position.dec;
state.is_slewing = true;
state.slew_start_time = Some(Instant::now());
state.slew_duration = duration;
log::info!(
"Mock mount slewing to RA={:.4}h, Dec={:.4}° (duration: {:.1}s)",
position.ra,
position.dec,
duration.as_secs_f64()
);
Ok(())
}
fn goto_altaz(&mut self, position: HorizontalPosition) -> MountResult<()> {
self.ensure_connected()?;
self.ensure_not_parked()?;
let state = self.state.lock().unwrap();
if state.mount_mode != MountMode::AltAz {
return Err(MountError::unsupported(
"Mount must be in Alt-Az mode for Az/Alt slewing",
));
}
if !position.is_above_horizon() {
return Err(MountError::BelowHorizon(position.altitude));
}
let jd = {
let dt = state.simulated_time;
let year = dt.format("%Y").to_string().parse::<i32>().unwrap();
let month = dt.format("%m").to_string().parse::<u8>().unwrap();
let day = dt.format("%d").to_string().parse::<f64>().unwrap();
let hour = dt.format("%H").to_string().parse::<f64>().unwrap();
let min = dt.format("%M").to_string().parse::<f64>().unwrap();
let sec = dt.format("%S").to_string().parse::<f64>().unwrap();
let day_frac = day + (hour + min / 60.0 + sec / 3600.0) / 24.0;
Coordinates::to_julian_date(year, month, day_frac)
};
let lst = Coordinates::julian_to_lst(jd, state.site_location.longitude);
let eq_pos = position.to_equatorial(state.site_location.latitude, lst);
drop(state); self.goto_equatorial(eq_pos)
}
fn is_slewing(&self) -> MountResult<bool> {
self.ensure_connected()?;
self.update_state();
Ok(self.state.lock().unwrap().is_slewing)
}
fn abort_slew(&mut self) -> MountResult<()> {
self.ensure_connected()?;
let mut state = self.state.lock().unwrap();
state.is_slewing = false;
state.slew_start_time = None;
state.moving_north = false;
state.moving_south = false;
state.moving_east = false;
state.moving_west = false;
log::info!("Mock mount slew aborted");
Ok(())
}
fn move_axis(&mut self, direction: Direction) -> MountResult<()> {
self.ensure_connected()?;
self.ensure_not_parked()?;
let mut state = self.state.lock().unwrap();
match direction {
Direction::North => state.moving_north = true,
Direction::South => state.moving_south = true,
Direction::East => state.moving_east = true,
Direction::West => state.moving_west = true,
}
log::debug!("Mock mount moving {:?}", direction);
Ok(())
}
fn stop_axis(&mut self, direction: Direction) -> MountResult<()> {
self.ensure_connected()?;
let mut state = self.state.lock().unwrap();
match direction {
Direction::North => state.moving_north = false,
Direction::South => state.moving_south = false,
Direction::East => state.moving_east = false,
Direction::West => state.moving_west = false,
}
log::debug!("Mock mount stopped {:?}", direction);
Ok(())
}
fn stop_all(&mut self) -> MountResult<()> {
self.ensure_connected()?;
let mut state = self.state.lock().unwrap();
state.is_slewing = false;
state.slew_start_time = None;
state.moving_north = false;
state.moving_south = false;
state.moving_east = false;
state.moving_west = false;
log::info!("Mock mount all movement stopped");
Ok(())
}
fn set_slew_rate(&mut self, rate: SlewRate) -> MountResult<()> {
self.ensure_connected()?;
self.state.lock().unwrap().slew_rate = rate;
log::debug!("Mock mount slew rate set to {}", rate.value());
Ok(())
}
fn set_tracking(&mut self, rate: TrackingRate) -> MountResult<()> {
self.ensure_connected()?;
self.ensure_not_parked()?;
let mut state = self.state.lock().unwrap();
state.tracking_rate = rate;
state.is_tracking = rate != TrackingRate::Off;
log::info!("Mock mount tracking rate set to {:?}", rate);
Ok(())
}
fn tracking_on(&mut self) -> MountResult<()> {
self.ensure_connected()?;
self.ensure_not_parked()?;
let mut state = self.state.lock().unwrap();
state.is_tracking = true;
log::info!("Mock mount tracking enabled");
Ok(())
}
fn tracking_off(&mut self) -> MountResult<()> {
self.ensure_connected()?;
let mut state = self.state.lock().unwrap();
state.is_tracking = false;
log::info!("Mock mount tracking disabled");
Ok(())
}
fn is_tracking(&self) -> MountResult<bool> {
self.ensure_connected()?;
Ok(self.state.lock().unwrap().is_tracking)
}
fn set_custom_tracking_rates(&mut self, rates: TrackingRates) -> MountResult<()> {
self.ensure_connected()?;
self.ensure_not_parked()?;
if !rates.is_valid_for_satellite() {
return Err(MountError::invalid_coords(format!(
"Tracking rates too high: RA={:.2}\"/s, Dec={:.2}\"/s",
rates.ra_rate, rates.dec_rate
)));
}
let mut state = self.state.lock().unwrap();
state.custom_ra_rate = rates.ra_rate;
state.custom_dec_rate = rates.dec_rate;
state.is_tracking = true;
log::info!(
"Mock mount custom tracking rates set: RA={:.2}\"/s, Dec={:.2}\"/s",
rates.ra_rate,
rates.dec_rate
);
Ok(())
}
fn set_guide_rate(&mut self, rate: f64) -> MountResult<()> {
self.ensure_connected()?;
let clamped = rate.clamp(0.1, 0.9);
self.state.lock().unwrap().guide_rate = clamped;
log::debug!("Mock mount guide rate set to {:.1}", clamped);
Ok(())
}
fn get_guide_rate(&self) -> MountResult<f64> {
self.ensure_connected()?;
Ok(self.state.lock().unwrap().guide_rate)
}
fn guide_pulse(&mut self, direction: Direction, duration_ms: u32) -> MountResult<()> {
self.ensure_connected()?;
self.ensure_not_parked()?;
let state = self.state.lock().unwrap();
let guide_rate = state.guide_rate;
let ra = state.ra;
let dec = state.dec;
drop(state);
let guide_speed = guide_rate * Coordinates::SIDEREAL_RATE_ARCSEC_PER_SEC / 3600.0; let duration_sec = duration_ms as f64 / 1000.0;
let movement = guide_speed * duration_sec;
let mut state = self.state.lock().unwrap();
match direction {
Direction::North => state.dec = (dec + movement).min(90.0),
Direction::South => state.dec = (dec - movement).max(-90.0),
Direction::East => state.ra = Coordinates::normalize_ra(ra - movement / 15.0),
Direction::West => state.ra = Coordinates::normalize_ra(ra + movement / 15.0),
}
log::debug!(
"Mock mount guide pulse: {:?} for {}ms",
direction,
duration_ms
);
Ok(())
}
fn set_altaz_mode(&mut self) -> MountResult<()> {
self.ensure_connected()?;
let mut state = self.state.lock().unwrap();
state.mount_mode = MountMode::AltAz;
log::info!("Mock mount set to Alt-Az mode");
Ok(())
}
fn set_polar_mode(&mut self) -> MountResult<()> {
self.ensure_connected()?;
let mut state = self.state.lock().unwrap();
state.mount_mode = MountMode::Equatorial;
log::info!("Mock mount set to Polar/Equatorial mode");
Ok(())
}
fn get_mount_mode(&self) -> MountResult<MountMode> {
self.ensure_connected()?;
let mode = self.state.lock().unwrap().mount_mode;
Ok(if mode == MountMode::Unknown {
MountMode::Equatorial
} else {
mode
})
}
fn go_home(&mut self) -> MountResult<()> {
self.ensure_connected()?;
self.ensure_not_parked()?;
self.goto_equatorial(EquatorialPosition::new(0.0, 90.0))
}
fn park(&mut self) -> MountResult<()> {
self.ensure_connected()?;
let mut state = self.state.lock().unwrap();
state.is_tracking = false;
state.is_slewing = false;
state.is_parked = true;
log::info!("Mock mount parked");
Ok(())
}
fn unpark(&mut self) -> MountResult<()> {
self.ensure_connected()?;
let mut state = self.state.lock().unwrap();
state.is_parked = false;
log::info!("Mock mount unparked");
Ok(())
}
fn is_parked(&self) -> MountResult<bool> {
self.ensure_connected()?;
Ok(self.state.lock().unwrap().is_parked)
}
fn sync(&mut self, position: EquatorialPosition) -> MountResult<()> {
self.ensure_connected()?;
let mut state = self.state.lock().unwrap();
state.ra = position.ra;
state.dec = position.dec;
log::info!(
"Mock mount synced to RA={:.4}h, Dec={:.4}°",
position.ra,
position.dec
);
Ok(())
}
fn set_site_location(&mut self, location: SiteLocation) -> MountResult<()> {
self.ensure_connected()?;
self.state.lock().unwrap().site_location = location;
log::info!(
"Mock mount site location set to lat={:.4}°, lon={:.4}°, alt={:.1}m",
location.latitude,
location.longitude,
location.altitude
);
Ok(())
}
fn get_site_location(&self) -> MountResult<SiteLocation> {
self.ensure_connected()?;
Ok(self.state.lock().unwrap().site_location)
}
fn get_status(&self) -> MountResult<MountStatus> {
self.ensure_connected()?;
self.update_state();
let state = self.state.lock().unwrap();
Ok(MountStatus {
is_tracking: state.is_tracking,
is_slewing: state.is_slewing,
mount_mode: state.mount_mode,
tracking_rate: state.tracking_rate,
is_parked: state.is_parked,
is_connected: state.connected,
slew_rate: state.slew_rate,
guide_rate: state.guide_rate,
pier_side: "East".to_string(), })
}
fn get_firmware_version(&self) -> MountResult<String> {
self.ensure_connected()?;
Ok(self.firmware_version.clone())
}
fn get_model(&self) -> MountResult<String> {
self.ensure_connected()?;
Ok(self.model_name.clone())
}
fn send_raw_command(&mut self, command: &str) -> MountResult<String> {
self.ensure_connected()?;
log::debug!("Mock mount raw command: {}", command);
Ok("1#".to_string())
}
}
impl SimulatedMount for MockMount {
fn advance_time(&mut self, duration: Duration) {
let mut state = self.state.lock().unwrap();
let simulated_elapsed = chrono::Duration::milliseconds((duration.as_millis()) as i64);
state.simulated_time = state.simulated_time + simulated_elapsed;
}
fn set_time_multiplier(&mut self, multiplier: f64) {
self.state.lock().unwrap().time_multiplier = multiplier.max(0.0);
}
fn get_simulation_time(&self) -> DateTime<Utc> {
self.state.lock().unwrap().simulated_time
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread::sleep;
#[test]
fn test_mock_mount_connect() {
let mut mount = MockMount::new();
assert!(!mount.is_connected());
mount.connect().unwrap();
assert!(mount.is_connected());
mount.disconnect().unwrap();
assert!(!mount.is_connected());
}
#[test]
fn test_mock_mount_position() {
let mut mount = MockMount::with_position(12.0, 45.0);
mount.connect().unwrap();
let pos = mount.get_position().unwrap();
assert!((pos.ra - 12.0).abs() < 0.001);
assert!((pos.dec - 45.0).abs() < 0.001);
}
#[test]
fn test_mock_mount_slew() {
let mut mount = MockMount::with_position(0.0, 0.0);
mount.connect().unwrap();
mount.unpark().unwrap();
let target = EquatorialPosition::new(1.0, 10.0);
mount.goto_equatorial(target).unwrap();
assert!(mount.is_slewing().unwrap());
for _ in 0..100 {
if !mount.is_slewing().unwrap() {
break;
}
sleep(Duration::from_millis(100));
}
assert!(!mount.is_slewing().unwrap());
let pos = mount.get_position().unwrap();
assert!((pos.ra - 1.0).abs() < 0.01);
assert!((pos.dec - 10.0).abs() < 0.01);
}
#[test]
fn test_mock_mount_tracking() {
let mut mount = MockMount::with_position(12.0, 45.0);
mount.connect().unwrap();
mount.unpark().unwrap();
assert!(!mount.is_tracking().unwrap());
mount.set_tracking(TrackingRate::Sidereal).unwrap();
assert!(mount.is_tracking().unwrap());
mount.tracking_off().unwrap();
assert!(!mount.is_tracking().unwrap());
}
#[test]
fn test_mock_mount_park() {
let mut mount = MockMount::new();
mount.connect().unwrap();
assert!(mount.is_parked().unwrap());
mount.unpark().unwrap();
assert!(!mount.is_parked().unwrap());
mount.park().unwrap();
assert!(mount.is_parked().unwrap());
}
#[test]
fn test_mock_mount_sync() {
let mut mount = MockMount::with_position(0.0, 0.0);
mount.connect().unwrap();
let sync_pos = EquatorialPosition::new(18.0, 38.0);
mount.sync(sync_pos).unwrap();
let pos = mount.get_position().unwrap();
assert!((pos.ra - 18.0).abs() < 0.001);
assert!((pos.dec - 38.0).abs() < 0.001);
}
#[test]
fn test_mock_mount_modes() {
let mut mount = MockMount::new();
mount.connect().unwrap();
assert_eq!(mount.get_mount_mode().unwrap(), MountMode::Equatorial);
mount.set_altaz_mode().unwrap();
assert_eq!(mount.get_mount_mode().unwrap(), MountMode::AltAz);
mount.set_polar_mode().unwrap();
assert_eq!(mount.get_mount_mode().unwrap(), MountMode::Equatorial);
}
#[test]
fn test_mock_mount_guide_rate() {
let mut mount = MockMount::new();
mount.connect().unwrap();
mount.set_guide_rate(0.5).unwrap();
assert!((mount.get_guide_rate().unwrap() - 0.5).abs() < 0.01);
mount.set_guide_rate(2.0).unwrap();
assert!((mount.get_guide_rate().unwrap() - 0.9).abs() < 0.01);
}
#[test]
fn test_mock_mount_site_location() {
let mut mount = MockMount::new();
mount.connect().unwrap();
let loc = SiteLocation::new(34.0522, -118.2437, 71.0);
mount.set_site_location(loc).unwrap();
let retrieved = mount.get_site_location().unwrap();
assert!((retrieved.latitude - 34.0522).abs() < 0.0001);
assert!((retrieved.longitude - (-118.2437)).abs() < 0.0001);
}
#[test]
fn test_mock_mount_not_connected_error() {
let mount = MockMount::new();
assert!(mount.get_position().is_err());
}
#[test]
fn test_mock_mount_parked_error() {
let mut mount = MockMount::new();
mount.connect().unwrap();
let target = EquatorialPosition::new(12.0, 45.0);
assert!(mount.goto_equatorial(target).is_err());
}
}