use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
North,
South,
East,
West,
}
impl fmt::Display for Direction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Direction::North => write!(f, "North"),
Direction::South => write!(f, "South"),
Direction::East => write!(f, "East"),
Direction::West => write!(f, "West"),
}
}
}
impl Direction {
pub fn opposite(&self) -> Self {
match self {
Direction::North => Direction::South,
Direction::South => Direction::North,
Direction::East => Direction::West,
Direction::West => Direction::East,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TrackingRate {
#[default]
Sidereal,
Lunar,
Solar,
Off,
}
impl fmt::Display for TrackingRate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TrackingRate::Sidereal => write!(f, "Sidereal"),
TrackingRate::Lunar => write!(f, "Lunar"),
TrackingRate::Solar => write!(f, "Solar"),
TrackingRate::Off => write!(f, "Off"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MountMode {
AltAz,
#[default]
Equatorial,
Unknown,
}
impl fmt::Display for MountMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MountMode::AltAz => write!(f, "Alt-Az"),
MountMode::Equatorial => write!(f, "Equatorial"),
MountMode::Unknown => write!(f, "Unknown"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SlewRate(u8);
impl SlewRate {
pub const GUIDE: SlewRate = SlewRate(0);
pub const CENTER: SlewRate = SlewRate(3);
pub const FIND: SlewRate = SlewRate(6);
pub const MAX: SlewRate = SlewRate(9);
pub fn new(rate: u8) -> Self {
SlewRate(rate.min(9))
}
pub fn value(&self) -> u8 {
self.0
}
}
impl Default for SlewRate {
fn default() -> Self {
SlewRate::FIND
}
}
impl From<u8> for SlewRate {
fn from(rate: u8) -> Self {
SlewRate::new(rate)
}
}
pub struct Protocol;
impl Protocol {
pub fn get_version() -> &'static str {
":GV#"
}
pub fn get_mount_model() -> &'static str {
":GVP#"
}
pub fn get_ra() -> &'static str {
":GR#"
}
pub fn get_dec() -> &'static str {
":GD#"
}
pub fn get_azimuth() -> &'static str {
":GZ#"
}
pub fn get_altitude() -> &'static str {
":GA#"
}
pub fn get_date() -> &'static str {
":GC#"
}
pub fn get_time() -> &'static str {
":GL#"
}
pub fn get_timezone() -> &'static str {
":GG#"
}
pub fn get_sidereal_time() -> &'static str {
":GS#"
}
pub fn get_latitude() -> &'static str {
":Gt#"
}
pub fn get_longitude() -> &'static str {
":Gg#"
}
pub fn get_guide_rate() -> &'static str {
":Ggr#"
}
pub fn get_tracking_status() -> &'static str {
":GAT#"
}
pub fn get_status() -> &'static str {
":GU#"
}
pub fn get_cardinal_direction() -> &'static str {
":Gm#"
}
pub fn get_meridian_settings() -> &'static str {
":GTa#"
}
pub fn get_buzzer_volume() -> &'static str {
":GBu#"
}
pub fn set_target_ra(hours: u8, minutes: u8, seconds: u8) -> String {
format!(":Sr{:02}:{:02}:{:02}#", hours, minutes, seconds)
}
pub fn set_target_azimuth(degrees: u16, minutes: u8, seconds: u8) -> String {
format!(":Sz{:03}*{:02}:{:02}#", degrees % 360, minutes, seconds)
}
pub fn set_target_azimuth_decimal(az_degrees: f64) -> String {
let az = if az_degrees < 0.0 {
az_degrees + 360.0
} else {
az_degrees % 360.0
};
let total_arcsec = (az * 3600.0).round() as u32;
let degrees = (total_arcsec / 3600) % 360;
let minutes = (total_arcsec % 3600) / 60;
let seconds = total_arcsec % 60;
format!(":Sz{:03}*{:02}:{:02}#", degrees, minutes, seconds)
}
pub fn set_target_altitude(degrees: i8, minutes: u8, seconds: u8) -> String {
let sign = if degrees >= 0 { '+' } else { '-' };
format!(
":Sa{}{}*{:02}:{:02}#",
sign,
degrees.unsigned_abs(),
minutes,
seconds
)
}
pub fn set_target_altitude_decimal(alt_degrees: f64) -> String {
let sign = if alt_degrees >= 0.0 { '+' } else { '-' };
let total_arcsec = (alt_degrees.abs() * 3600.0).round() as u32;
let degrees = total_arcsec / 3600;
let minutes = (total_arcsec % 3600) / 60;
let seconds = total_arcsec % 60;
format!(":Sa{}{}*{:02}:{:02}#", sign, degrees, minutes, seconds)
}
pub fn set_target_ra_decimal(ra_hours: f64) -> String {
let total_seconds = (ra_hours * 3600.0).round() as u32;
let hours = (total_seconds / 3600) % 24;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
format!(":Sr{:02}:{:02}:{:02}#", hours, minutes, seconds)
}
pub fn set_target_dec(degrees: i16, minutes: u8, seconds: u8) -> String {
let sign = if degrees >= 0 { '+' } else { '-' };
format!(
":Sd{}{}*{:02}:{:02}#",
sign,
degrees.unsigned_abs(),
minutes,
seconds
)
}
pub fn set_target_dec_decimal(dec_degrees: f64) -> String {
let sign = if dec_degrees >= 0.0 { '+' } else { '-' };
let total_arcsec = (dec_degrees.abs() * 3600.0).round() as u32;
let degrees = total_arcsec / 3600;
let minutes = (total_arcsec % 3600) / 60;
let seconds = total_arcsec % 60;
format!(":Sd{}{}*{:02}:{:02}#", sign, degrees, minutes, seconds)
}
pub fn goto() -> &'static str {
":MS#"
}
pub fn sync() -> &'static str {
":CM#"
}
pub fn stop_all() -> &'static str {
":Q#"
}
pub fn move_north() -> &'static str {
":Mn#"
}
pub fn move_south() -> &'static str {
":Ms#"
}
pub fn move_east() -> &'static str {
":Me#"
}
pub fn move_west() -> &'static str {
":Mw#"
}
pub fn move_direction(direction: Direction) -> &'static str {
match direction {
Direction::North => Self::move_north(),
Direction::South => Self::move_south(),
Direction::East => Self::move_east(),
Direction::West => Self::move_west(),
}
}
pub fn stop_north() -> &'static str {
":Qn#"
}
pub fn stop_south() -> &'static str {
":Qs#"
}
pub fn stop_east() -> &'static str {
":Qe#"
}
pub fn stop_west() -> &'static str {
":Qw#"
}
pub fn stop_direction(direction: Direction) -> &'static str {
match direction {
Direction::North => Self::stop_north(),
Direction::South => Self::stop_south(),
Direction::East => Self::stop_east(),
Direction::West => Self::stop_west(),
}
}
pub fn set_slew_rate(rate: SlewRate) -> String {
format!(":R{}#", rate.value())
}
pub fn set_guide_rate(rate: f64) -> String {
let clamped = rate.clamp(0.1, 0.9);
format!(":Rg{:.1}#", clamped)
}
pub fn guide_pulse_north(ms: u32) -> String {
format!(":Mgn{:04}#", ms.min(9999))
}
pub fn guide_pulse_south(ms: u32) -> String {
format!(":Mgs{:04}#", ms.min(9999))
}
pub fn guide_pulse_east(ms: u32) -> String {
format!(":Mge{:04}#", ms.min(9999))
}
pub fn guide_pulse_west(ms: u32) -> String {
format!(":Mgw{:04}#", ms.min(9999))
}
pub fn guide_pulse(direction: Direction, ms: u32) -> String {
match direction {
Direction::North => Self::guide_pulse_north(ms),
Direction::South => Self::guide_pulse_south(ms),
Direction::East => Self::guide_pulse_east(ms),
Direction::West => Self::guide_pulse_west(ms),
}
}
pub fn tracking_on() -> &'static str {
":Te#"
}
pub fn tracking_off() -> &'static str {
":Td#"
}
pub fn tracking_sidereal() -> &'static str {
":TQ#"
}
pub fn tracking_lunar() -> &'static str {
":TL#"
}
pub fn tracking_solar() -> &'static str {
":TS#"
}
pub fn set_tracking_rate(rate: TrackingRate) -> &'static str {
match rate {
TrackingRate::Sidereal => Self::tracking_sidereal(),
TrackingRate::Lunar => Self::tracking_lunar(),
TrackingRate::Solar => Self::tracking_solar(),
TrackingRate::Off => Self::tracking_off(),
}
}
pub fn find_home() -> &'static str {
":hC#"
}
pub fn goto_park() -> &'static str {
":hP#"
}
pub fn unpark() -> &'static str {
":hR#"
}
pub fn clear_alignment() -> &'static str {
":SRC#"
}
pub fn set_altaz_mode() -> &'static str {
":AA#"
}
pub fn set_polar_mode() -> &'static str {
":AP#"
}
pub fn set_date(month: u8, day: u8, year: u8) -> String {
format!(":SC{:02}/{:02}/{:02}#", month, day, year % 100)
}
pub fn set_time(hour: u8, minute: u8, second: u8) -> String {
format!(":SL{:02}:{:02}:{:02}#", hour, minute, second)
}
pub fn set_timezone(offset: i8) -> String {
let sign = if offset >= 0 { '+' } else { '-' };
format!(":SG{}{:02}#", sign, offset.abs())
}
pub fn set_latitude(latitude: f64) -> String {
let sign = if latitude >= 0.0 { '+' } else { '-' };
let total_arcmin = (latitude.abs() * 60.0).round() as u32;
let degrees = total_arcmin / 60;
let minutes = total_arcmin % 60;
format!(":St{}{}*{:02}#", sign, degrees, minutes)
}
pub fn set_longitude(longitude: f64) -> String {
let lon = if longitude < 0.0 {
longitude + 360.0
} else {
longitude
};
let total_arcmin = (lon * 60.0).round() as u32;
let degrees = total_arcmin / 60;
let minutes = total_arcmin % 60;
format!(":Sg{:03}*{:02}#", degrees, minutes)
}
pub fn set_meridian_action(action: u8) -> String {
format!(":STa{}#", action.min(1))
}
pub fn set_buzzer_volume(volume: u8) -> String {
format!(":SBu{}#", volume.min(2))
}
}
pub struct ResponseParser;
impl ResponseParser {
pub fn parse_bool(response: &str) -> Option<bool> {
let trimmed = response.trim_end_matches('#');
match trimmed {
"0" => Some(false),
"1" => Some(true),
_ => None,
}
}
pub fn parse_ra(response: &str) -> Option<(u8, u8, f64)> {
let trimmed = response.trim_end_matches('#');
let parts: Vec<&str> = trimmed.split(':').collect();
if parts.len() >= 3 {
let hours = parts[0].parse().ok()?;
let minutes = parts[1].parse().ok()?;
let seconds = parts[2].parse().ok()?;
Some((hours, minutes, seconds))
} else if parts.len() == 2 {
let hours = parts[0].parse().ok()?;
let min_frac: f64 = parts[1].parse().ok()?;
let minutes = min_frac.floor() as u8;
let seconds = min_frac.fract() * 60.0;
Some((hours, minutes, seconds))
} else {
None
}
}
pub fn parse_dec(response: &str) -> Option<(i16, u8, f64)> {
let trimmed = response.trim_end_matches('#');
let (sign, rest) = if trimmed.starts_with('+') {
(1i16, &trimmed[1..])
} else if trimmed.starts_with('-') {
(-1i16, &trimmed[1..])
} else {
(1i16, trimmed)
};
let rest = rest.replace('°', "*");
let parts: Vec<&str> = rest.split('*').collect();
if parts.len() < 2 {
return None;
}
let degrees: i16 = parts[0].parse().ok()?;
let min_sec: Vec<&str> = parts[1].split(':').collect();
let minutes: u8 = min_sec[0].parse().ok()?;
let seconds: f64 = if min_sec.len() > 1 {
min_sec[1].parse().ok()?
} else {
0.0
};
Some((sign * degrees, minutes, seconds))
}
pub fn parse_azimuth(response: &str) -> Option<(u16, u8, f64)> {
let trimmed = response.trim_end_matches('#');
let rest = trimmed.replace('°', "*");
let parts: Vec<&str> = rest.split('*').collect();
if parts.len() < 2 {
return None;
}
let degrees: u16 = parts[0].parse().ok()?;
let min_sec: Vec<&str> = parts[1].split(':').collect();
let minutes: u8 = min_sec[0].parse().ok()?;
let seconds: f64 = if min_sec.len() > 1 {
min_sec[1].parse().ok()?
} else {
0.0
};
Some((degrees, minutes, seconds))
}
pub fn parse_altitude(response: &str) -> Option<(i8, u8, f64)> {
let (degrees, minutes, seconds) = Self::parse_dec(response)?;
Some((degrees as i8, minutes, seconds))
}
pub fn parse_goto_response(response: &str) -> Result<(), String> {
let trimmed = response.trim_end_matches('#');
match trimmed {
"0" => Ok(()),
"1" => Err("Object below horizon".to_string()),
"2" => Err("Object below minimum elevation".to_string()),
"4" => Err("Position unreachable".to_string()),
"5" => Err("Not aligned".to_string()),
"6" => Err("Outside limits".to_string()),
"7" | "e7" => Err("Pier side limit".to_string()),
other => Err(format!("Unknown error: {}", other)),
}
}
pub fn parse_status(response: &str) -> (bool, bool, bool, MountMode) {
let flags = response.trim_end_matches('#');
let tracking = !flags.contains('n');
let slewing = !flags.contains('N');
let at_home = flags.contains('H');
let mount_mode = if flags.contains('G') {
MountMode::Equatorial
} else if flags.contains('Z') {
MountMode::AltAz
} else {
MountMode::Unknown
};
(tracking, slewing, at_home, mount_mode)
}
pub fn parse_tracking_status(response: &str) -> Option<bool> {
Self::parse_bool(response)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_direction_opposite() {
assert_eq!(Direction::North.opposite(), Direction::South);
assert_eq!(Direction::South.opposite(), Direction::North);
assert_eq!(Direction::East.opposite(), Direction::West);
assert_eq!(Direction::West.opposite(), Direction::East);
}
#[test]
fn test_slew_rate() {
assert_eq!(SlewRate::GUIDE.value(), 0);
assert_eq!(SlewRate::MAX.value(), 9);
assert_eq!(SlewRate::new(10).value(), 9); assert_eq!(SlewRate::new(5).value(), 5);
}
#[test]
fn test_set_target_ra() {
let cmd = Protocol::set_target_ra(12, 30, 45);
assert_eq!(cmd, ":Sr12:30:45#");
}
#[test]
fn test_set_target_ra_decimal() {
let cmd = Protocol::set_target_ra_decimal(12.5);
assert_eq!(cmd, ":Sr12:30:00#");
}
#[test]
fn test_set_target_dec() {
let cmd = Protocol::set_target_dec(45, 30, 15);
assert_eq!(cmd, ":Sd+45*30:15#");
let cmd = Protocol::set_target_dec(-23, 26, 21);
assert_eq!(cmd, ":Sd-23*26:21#");
}
#[test]
fn test_set_target_dec_decimal() {
let cmd = Protocol::set_target_dec_decimal(45.5);
assert_eq!(cmd, ":Sd+45*30:00#");
let cmd = Protocol::set_target_dec_decimal(-23.5);
assert_eq!(cmd, ":Sd-23*30:00#");
}
#[test]
fn test_guide_pulse() {
let cmd = Protocol::guide_pulse_north(500);
assert_eq!(cmd, ":Mgn0500#");
let cmd = Protocol::guide_pulse(Direction::East, 150);
assert_eq!(cmd, ":Mge0150#");
}
#[test]
fn test_set_slew_rate() {
let cmd = Protocol::set_slew_rate(SlewRate::MAX);
assert_eq!(cmd, ":R9#");
let cmd = Protocol::set_slew_rate(SlewRate::GUIDE);
assert_eq!(cmd, ":R0#");
}
#[test]
fn test_set_latitude() {
let cmd = Protocol::set_latitude(46.5);
assert_eq!(cmd, ":St+46*30#");
let cmd = Protocol::set_latitude(-33.75);
assert_eq!(cmd, ":St-33*45#");
}
#[test]
fn test_set_longitude() {
let cmd = Protocol::set_longitude(6.25);
assert_eq!(cmd, ":Sg006*15#");
let cmd = Protocol::set_longitude(-118.5);
assert_eq!(cmd, ":Sg241*30#");
}
#[test]
fn test_parse_bool() {
assert_eq!(ResponseParser::parse_bool("0#"), Some(false));
assert_eq!(ResponseParser::parse_bool("1#"), Some(true));
assert_eq!(ResponseParser::parse_bool("invalid"), None);
}
#[test]
fn test_parse_ra() {
let (h, m, s) = ResponseParser::parse_ra("12:30:45#").unwrap();
assert_eq!(h, 12);
assert_eq!(m, 30);
assert!((s - 45.0).abs() < 0.001);
}
#[test]
fn test_parse_dec() {
let (d, m, s) = ResponseParser::parse_dec("+45*30:15#").unwrap();
assert_eq!(d, 45);
assert_eq!(m, 30);
assert!((s - 15.0).abs() < 0.001);
let (d, m, s) = ResponseParser::parse_dec("-23*26:21#").unwrap();
assert_eq!(d, -23);
assert_eq!(m, 26);
assert!((s - 21.0).abs() < 0.001);
}
#[test]
fn test_parse_goto_response() {
assert!(ResponseParser::parse_goto_response("0#").is_ok());
assert!(ResponseParser::parse_goto_response("1#").is_err());
assert!(ResponseParser::parse_goto_response("7#").is_err());
}
#[test]
fn test_parse_status() {
let (tracking, slewing, at_home, mode) = ResponseParser::parse_status("NG#");
assert!(tracking);
assert!(!slewing);
assert!(!at_home);
assert_eq!(mode, MountMode::Equatorial);
let (tracking, slewing, at_home, mode) = ResponseParser::parse_status("nNHZ#");
assert!(!tracking);
assert!(!slewing);
assert!(at_home);
assert_eq!(mode, MountMode::AltAz);
}
#[test]
fn test_tracking_commands() {
assert_eq!(Protocol::set_tracking_rate(TrackingRate::Sidereal), ":TQ#");
assert_eq!(Protocol::set_tracking_rate(TrackingRate::Lunar), ":TL#");
assert_eq!(Protocol::set_tracking_rate(TrackingRate::Solar), ":TS#");
assert_eq!(Protocol::set_tracking_rate(TrackingRate::Off), ":Td#");
}
#[test]
fn test_set_target_azimuth() {
assert_eq!(Protocol::set_target_azimuth(180, 30, 45), ":Sz180*30:45#");
assert_eq!(Protocol::set_target_azimuth(0, 0, 0), ":Sz000*00:00#");
assert_eq!(Protocol::set_target_azimuth(359, 59, 59), ":Sz359*59:59#");
}
#[test]
fn test_set_target_azimuth_decimal() {
assert_eq!(Protocol::set_target_azimuth_decimal(180.0), ":Sz180*00:00#");
assert_eq!(Protocol::set_target_azimuth_decimal(90.5), ":Sz090*30:00#");
assert_eq!(Protocol::set_target_azimuth_decimal(0.0), ":Sz000*00:00#");
assert_eq!(Protocol::set_target_azimuth_decimal(-90.0), ":Sz270*00:00#");
}
#[test]
fn test_set_target_altitude() {
assert_eq!(Protocol::set_target_altitude(45, 30, 15), ":Sa+45*30:15#");
assert_eq!(Protocol::set_target_altitude(-10, 15, 30), ":Sa-10*15:30#");
assert_eq!(Protocol::set_target_altitude(0, 0, 0), ":Sa+0*00:00#");
assert_eq!(Protocol::set_target_altitude(90, 0, 0), ":Sa+90*00:00#");
}
#[test]
fn test_set_target_altitude_decimal() {
assert_eq!(Protocol::set_target_altitude_decimal(45.0), ":Sa+45*00:00#");
assert_eq!(
Protocol::set_target_altitude_decimal(-10.5),
":Sa-10*30:00#"
);
assert_eq!(Protocol::set_target_altitude_decimal(0.0), ":Sa+0*00:00#");
assert_eq!(Protocol::set_target_altitude_decimal(90.0), ":Sa+90*00:00#");
}
}