diff --git a/.gitattributes b/.gitattributes index 3fd858755..0905eafb0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ "console_backend/tests/data/*" filter=lfs diff=lfs merge=lfs -text installers/Windows/NSIS/*.zip filter=lfs diff=lfs merge=lfs -text +binaries/** filter=lfs diff=lfs merge=lfs -text diff --git a/Makefile.toml b/Makefile.toml index b4aca17bb..96b87d044 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -311,8 +311,12 @@ args = [ command = "${DIST_PYTHON}" args = ["-m", "pip", "install", "flit"] +[tasks.check-git-status] +command = "git" +args = ["status"] + [tasks.build-frontend-wheel] -dependencies = ["dist-install-pip-flit"] +dependencies = ["dist-install-pip-flit", "check-git-status"] command = "${DIST_PYTHON}" args = ["-m", "flit", "build", "--no-setup-py"] @@ -572,6 +576,9 @@ args = ["-m", "pip", "install", "console_backend/dist/${BACKEND_WHEEL}"] script_runner = "@duckscript" script = ''' cp src/main/resources py311-dist/ + +os = os_family +glob_cp binaries/${os}/rtcm3tosbp* py311-dist/binaries/${os} ''' [tasks.build-dist-freeze] diff --git a/binaries/linux/rtcm3tosbp b/binaries/linux/rtcm3tosbp new file mode 100755 index 000000000..e90014df4 --- /dev/null +++ b/binaries/linux/rtcm3tosbp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7e6c5a8c70d9a15689037b4b6f98fce0f647caa3b6d4448731951bffe27ce291 +size 809440 diff --git a/binaries/mac/rtcm3tosbp b/binaries/mac/rtcm3tosbp new file mode 100755 index 000000000..707d4e521 --- /dev/null +++ b/binaries/mac/rtcm3tosbp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f31913dd402a2c98f0ea792a1ae4a58608ee98557a38525b80da3fc59b26624a +size 795824 diff --git a/binaries/windows/rtcm3tosbp.exe b/binaries/windows/rtcm3tosbp.exe new file mode 100644 index 000000000..aac10bdb1 --- /dev/null +++ b/binaries/windows/rtcm3tosbp.exe @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c01f35d1c955c418590e2c87d328f54c0c067a0d8af8773968211e7a50031981 +size 814080 diff --git a/console_backend/src/cli_options.rs b/console_backend/src/cli_options.rs index 53015b984..da0e0f04f 100644 --- a/console_backend/src/cli_options.rs +++ b/console_backend/src/cli_options.rs @@ -105,6 +105,10 @@ pub struct CliOptions { #[clap(long)] pub enable_map: bool, + /// Enable ntrip client + #[clap(long)] + pub enable_ntrip: bool, + /// Path to a yaml file containing known piksi settings. #[clap(long)] pub settings_yaml: Option, diff --git a/console_backend/src/common_constants.rs b/console_backend/src/common_constants.rs index d33615ace..a04edda72 100644 --- a/console_backend/src/common_constants.rs +++ b/console_backend/src/common_constants.rs @@ -293,6 +293,8 @@ pub enum Keys { NOTIFICATION, #[strum(serialize = "SOLUTION_LINE")] SOLUTION_LINE, + #[strum(serialize = "NTRIP_DISPLAY")] + NTRIP_DISPLAY, } #[derive(Clone, Debug, Display, EnumString, EnumVariantNames, Eq, Hash, PartialEq)] diff --git a/console_backend/src/connection.rs b/console_backend/src/connection.rs index c8d4f9cf6..6fd1efeb2 100644 --- a/console_backend/src/connection.rs +++ b/console_backend/src/connection.rs @@ -182,6 +182,7 @@ fn conn_manager_thd( ConnectionState::Connected { conn: conn.clone(), stop_token, + msg_sender: msg_sender.clone(), }, &client_sender, ); diff --git a/console_backend/src/lib.rs b/console_backend/src/lib.rs index 6c22e0a33..8aa82e075 100644 --- a/console_backend/src/lib.rs +++ b/console_backend/src/lib.rs @@ -32,6 +32,7 @@ pub mod fft_monitor; pub mod fileio; pub mod fusion_status_flags; pub mod log_panel; +pub mod ntrip_output; pub mod output; pub mod piksi_tools_constants; pub mod process_messages; @@ -47,9 +48,9 @@ pub mod updater; pub mod utils; pub mod watch; +use crate::client_sender::BoxedClientSender; +use crate::shared_state::SharedState; use crate::status_bar::StatusBar; -use std::sync::Mutex; - use crate::tabs::{ advanced_tab::{ advanced_imu_tab::AdvancedImuTab, advanced_magnetometer_tab::AdvancedMagnetometerTab, @@ -69,6 +70,8 @@ use crate::tabs::{ }, update_tab::UpdateTab, }; +use crate::types::MsgSender; +use std::sync::Mutex; #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; @@ -89,13 +92,14 @@ struct Tabs { pub status_bar: Mutex, pub update: Mutex, pub settings: Option, // settings only enabled on TCP / Serial + pub shared_state: SharedState, } impl Tabs { fn new( - shared_state: shared_state::SharedState, - client_sender: client_sender::BoxedClientSender, - msg_sender: types::MsgSender, + shared_state: SharedState, + client_sender: BoxedClientSender, + msg_sender: MsgSender, ) -> Self { Self { main: MainTab::new(shared_state.clone(), client_sender.clone()).into(), @@ -139,15 +143,16 @@ impl Tabs { ) .into(), status_bar: StatusBar::new(shared_state.clone()).into(), - update: UpdateTab::new(shared_state).into(), + update: UpdateTab::new(shared_state.clone()).into(), settings: None, + shared_state, } } fn with_settings( - shared_state: shared_state::SharedState, - client_sender: client_sender::BoxedClientSender, - msg_sender: types::MsgSender, + shared_state: SharedState, + client_sender: BoxedClientSender, + msg_sender: MsgSender, ) -> Self { let mut tabs = Self::new( shared_state.clone(), diff --git a/console_backend/src/ntrip_output.rs b/console_backend/src/ntrip_output.rs new file mode 100644 index 000000000..ff8de34ef --- /dev/null +++ b/console_backend/src/ntrip_output.rs @@ -0,0 +1,103 @@ +use crate::tabs::advanced_tab::ntrip_tab::OutputType; +use crate::types::Result; +use crate::utils::pythonhome_dir; +use anyhow::Context; +use crossbeam::channel::Receiver; +use log::{error, info}; +use std::io::Write; +use std::process::{Command, Stdio}; +use std::{io, thread}; + +pub struct MessageConverter { + in_rx: Receiver>, + output_type: OutputType, +} + +impl MessageConverter { + pub fn new(in_rx: Receiver>, output_type: OutputType) -> Self { + Self { in_rx, output_type } + } + + pub fn start(&mut self, out: W) -> Result<()> { + match self.output_type { + OutputType::RTCM => self.output_rtcm(out), + OutputType::SBP => self.output_sbp(out), + } + } + + /// Just redirects directly to writer + fn output_rtcm(&mut self, mut out: W) -> Result<()> { + let in_rx = self.in_rx.clone(); + thread::spawn(move || loop { + if let Ok(data) = in_rx.recv() { + if let Err(e) = out.write(&data) { + error!("failed to write to device {e}"); + } + } + }); + Ok(()) + } + + /// Runs rtcm3tosbp converter + fn output_sbp(&mut self, mut out: W) -> Result<()> { + let mut child = if cfg!(target_os = "windows") { + let mut cmd = Command::new("cmd"); + let rtcm = pythonhome_dir()? + .join("binaries") + .join("windows") + .join("rtcm3tosbp.exe") + .to_string_lossy() + .to_string(); + info!("running rtcm3tosbp from \"{}\"", rtcm); + cmd.args(["/C", &rtcm]); + cmd + } else if cfg!(target_os = "macos") { + let mut cmd = Command::new("sh"); + let rtcm = pythonhome_dir()? + .join("binaries") + .join("mac") + .join("rtcm3tosbp") + .to_string_lossy() + .to_string(); + info!("running rtcm3tosbp from \"{}\"", rtcm); + cmd.args(["-c", &rtcm]); + cmd + } else { + let mut cmd = Command::new("sh"); + let rtcm = pythonhome_dir()? + .join("binaries") + .join("linux") + .join("rtcm3tosbp") + .to_string_lossy() + .to_string(); + info!("running rtcm3tosbp from \"{}\"", rtcm); + cmd.args(["-c", &rtcm]); + cmd + }; + let mut child = child + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .context("rtcm converter process failed")?; + + let mut child_in = child.stdin.take().context("rtcm3tosbp stdin missing")?; + let mut child_out = child.stdout.take().context("rtcm3tosbp stdout missing")?; + let in_rx = self.in_rx.clone(); + + thread::spawn(move || { + if let Err(e) = io::copy(&mut child_out, &mut out) { + error!("failed to write to device {e}"); + } + }); + thread::spawn(move || { + while let Ok(data) = in_rx.recv() { + if let Err(e) = child_in.write_all(&data) { + error!("failed to write to rtcm3tosbp {e}") + } + } + }); + thread::spawn(move || child.wait()); + Ok(()) + } +} diff --git a/console_backend/src/process_messages.rs b/console_backend/src/process_messages.rs index 19a84de1d..e51560459 100644 --- a/console_backend/src/process_messages.rs +++ b/console_backend/src/process_messages.rs @@ -302,7 +302,12 @@ fn register_events(link: sbp::link::Link) { .lock() .unwrap() .handle_pos_llh(msg.clone()); - tabs.status_bar.lock().unwrap().handle_pos_llh(msg); + tabs.status_bar.lock().unwrap().handle_pos_llh(msg.clone()); + + // ntrip tab dynamic position + let mut guard = tabs.shared_state.lock(); + let ntrip = &mut guard.ntrip_tab; + ntrip.set_last_data(msg); }); link.register(|tabs: &Tabs, msg: MsgPosLlhCov| { tabs.solution_position diff --git a/console_backend/src/server_recv_thread.rs b/console_backend/src/server_recv_thread.rs index c9bf414c8..752025ff8 100644 --- a/console_backend/src/server_recv_thread.rs +++ b/console_backend/src/server_recv_thread.rs @@ -35,7 +35,8 @@ use crate::errors::{ }; use crate::log_panel::LogLevel; use crate::output::CsvLogging; -use crate::shared_state::{AdvancedNetworkingState, SharedState}; +use crate::shared_state::{AdvancedNetworkingState, ConnectionState, SharedState}; +use crate::tabs::advanced_tab::ntrip_tab::NtripOptions; use crate::tabs::{ settings_tab::SaveRequest, solution_tab::LatLonUnits, update_tab::UpdateTabUpdate, }; @@ -344,6 +345,54 @@ pub fn server_recv_thread( .expect(CAP_N_PROTO_DESERIALIZATION_FAILURE); shared_state.switch_tab(curr_tab); } + m::message::NtripConnect(Ok(cv_in)) => { + let url = cv_in + .get_url() + .expect(CAP_N_PROTO_DESERIALIZATION_FAILURE) + .to_string(); + let usr = cv_in + .get_username() + .expect(CAP_N_PROTO_DESERIALIZATION_FAILURE) + .to_string(); + let pwd = cv_in + .get_password() + .expect(CAP_N_PROTO_DESERIALIZATION_FAILURE) + .to_string(); + let gga_period = cv_in.get_gga_period(); + let output_type = cv_in + .get_output_type() + .expect(CAP_N_PROTO_DESERIALIZATION_FAILURE) + .to_string(); + let position: Option<(f64, f64, f64)> = match cv_in.get_position().which() { + Ok(m::ntrip_connect::position::Pos(Ok(pos))) => { + Some((pos.get_lat(), pos.get_lon(), pos.get_alt())) + } + Err(e) => { + error!("{}", e); + None + } + _ => None, + }; + let mut guard = shared_state.lock(); + let heartbeat = guard.heartbeat_data.clone(); + match guard.conn.get() { + ConnectionState::Connected { msg_sender, .. } => { + let options = NtripOptions::new( + url, + usr, + pwd, + position, + gga_period, + &output_type, + ); + guard.ntrip_tab.connect(msg_sender, heartbeat, options); + } + _ => error!("ntrip unable to find connected device"), + } + } + m::message::NtripDisconnect(Ok(_)) => { + shared_state.lock().ntrip_tab.disconnect(); + } _ => { error!("unknown message from front-end"); } diff --git a/console_backend/src/shared_state.rs b/console_backend/src/shared_state.rs index 85c25639b..e7375512d 100644 --- a/console_backend/src/shared_state.rs +++ b/console_backend/src/shared_state.rs @@ -52,10 +52,11 @@ use crate::log_panel::LogLevel; use crate::output::{CsvLogging, CsvSerializer}; use crate::process_messages::StopToken; use crate::shared_state::EventType::Refresh; +use crate::tabs::advanced_tab::ntrip_tab::NtripState; use crate::tabs::{settings_tab, solution_tab::LatLonUnits, update_tab::UpdateTabUpdate}; use crate::utils::{send_conn_state, OkOrLog}; use crate::watch::{WatchReceiver, Watched}; -use crate::{common_constants::ConnectionType, connection::Connection}; +use crate::{common_constants::ConnectionType, connection::Connection, MsgSender}; use crate::{ common_constants::{self as cc, SbpLogging}, status_bar::Heartbeat, @@ -347,6 +348,13 @@ impl SharedState { self.lock().heartbeat_data.clone() } + pub fn msg_sender(&self) -> Option { + match self.connection() { + ConnectionState::Connected { msg_sender, .. } => Some(msg_sender), + _ => None, + } + } + pub fn set_check_visibility(&self, check_visibility: Vec) { let mut guard = self.lock(); guard.tracking_tab.signals_tab.check_visibility = check_visibility; @@ -379,6 +387,7 @@ impl Clone for SharedState { pub struct SharedStateInner { pub(crate) logging_bar: LoggingBarState, pub(crate) log_panel: LogPanelState, + pub(crate) ntrip_tab: NtripState, pub(crate) tracking_tab: TrackingTabState, pub(crate) connection_history: ConnectionHistory, pub(crate) conn: Watched, @@ -408,6 +417,7 @@ impl SharedStateInner { SharedStateInner { logging_bar: LoggingBarState::new(log_directory), log_panel: LogPanelState::new(), + ntrip_tab: NtripState::default(), tracking_tab: TrackingTabState::new(), debug: false, connection_history, @@ -922,6 +932,7 @@ pub enum ConnectionState { Connected { conn: Connection, stop_token: StopToken, + msg_sender: MsgSender, }, /// Attempting to connect diff --git a/console_backend/src/status_bar.rs b/console_backend/src/status_bar.rs index 3e180e765..5c3a7cf3e 100644 --- a/console_backend/src/status_bar.rs +++ b/console_backend/src/status_bar.rs @@ -82,6 +82,9 @@ pub struct StatusBarUpdate { ant_status: String, port: String, version: String, + ntrip_connected: bool, + ntrip_upload_bytes: f64, + ntrip_download_bytes: f64, } impl StatusBarUpdate { pub fn new() -> StatusBarUpdate { @@ -96,6 +99,9 @@ impl StatusBarUpdate { ant_status: String::from(EMPTY_STR), port: String::from(""), version: String::from(""), + ntrip_connected: false, + ntrip_upload_bytes: 0.0, + ntrip_download_bytes: 0.0, } } } @@ -188,6 +194,9 @@ impl StatusBar { sb_update.port + " - " }; status_bar_status.set_title(&format!("{port}Swift Console {}", sb_update.version)); + status_bar_status.set_ntrip_upload(sb_update.ntrip_upload_bytes); + status_bar_status.set_ntrip_download(sb_update.ntrip_download_bytes); + status_bar_status.set_ntrip_connected(sb_update.ntrip_connected); client_sender.send_data(serialize_capnproto_builder(builder)); } @@ -335,6 +344,9 @@ pub struct HeartbeatInner { last_bytes_read: usize, last_time_bytes_read: Instant, version: String, + ntrip_connected: bool, + ntrip_upload_bytes: f64, + ntrip_download_bytes: f64, } impl HeartbeatInner { pub fn new() -> HeartbeatInner { @@ -368,6 +380,9 @@ impl HeartbeatInner { last_bytes_read: 0, last_time_bytes_read: Instant::now(), version: String::from(""), + ntrip_download_bytes: 0.0, + ntrip_upload_bytes: 0.0, + ntrip_connected: false, } } @@ -488,6 +503,9 @@ impl HeartbeatInner { solid_connection: self.solid_connection, port: self.port.clone(), version: self.version.clone(), + ntrip_connected: self.ntrip_connected, + ntrip_upload_bytes: self.ntrip_upload_bytes, + ntrip_download_bytes: self.ntrip_download_bytes, } } else { let packet = StatusBarUpdate { @@ -495,6 +513,9 @@ impl HeartbeatInner { ant_status: self.ant_status.clone(), num_sats: self.llh_num_sats, version: self.version.clone(), + ntrip_connected: self.ntrip_connected, + ntrip_upload_bytes: self.ntrip_upload_bytes, + ntrip_download_bytes: self.ntrip_download_bytes, ..Default::default() }; self.llh_num_sats = 0; @@ -546,6 +567,26 @@ impl Heartbeat { pub fn reset(&mut self) { *self.lock().expect(HEARTBEAT_LOCK_MUTEX_FAILURE) = HeartbeatInner::new(); } + pub fn set_ntrip_ul(&mut self, ul: f64) { + self.lock() + .expect(HEARTBEAT_LOCK_MUTEX_FAILURE) + .ntrip_upload_bytes = ul + } + pub fn set_ntrip_dl(&mut self, dl: f64) { + self.lock() + .expect(HEARTBEAT_LOCK_MUTEX_FAILURE) + .ntrip_download_bytes = dl + } + pub fn set_ntrip_connected(&mut self, connected: bool) { + self.lock() + .expect(HEARTBEAT_LOCK_MUTEX_FAILURE) + .ntrip_connected = connected + } + pub fn get_ntrip_connected(&self) -> bool { + self.lock() + .expect(HEARTBEAT_LOCK_MUTEX_FAILURE) + .ntrip_connected + } } impl Deref for Heartbeat { diff --git a/console_backend/src/tabs/advanced_tab/mod.rs b/console_backend/src/tabs/advanced_tab/mod.rs index 1e0b4e45c..6eea07338 100644 --- a/console_backend/src/tabs/advanced_tab/mod.rs +++ b/console_backend/src/tabs/advanced_tab/mod.rs @@ -22,3 +22,4 @@ pub mod advanced_magnetometer_tab; pub mod advanced_networking_tab; pub mod advanced_spectrum_analyzer_tab; pub mod advanced_system_monitor_tab; +pub mod ntrip_tab; diff --git a/console_backend/src/tabs/advanced_tab/ntrip_tab.rs b/console_backend/src/tabs/advanced_tab/ntrip_tab.rs new file mode 100644 index 000000000..fff955583 --- /dev/null +++ b/console_backend/src/tabs/advanced_tab/ntrip_tab.rs @@ -0,0 +1,399 @@ +use crate::ntrip_output::MessageConverter; +use crate::status_bar::Heartbeat; +use crate::types::{ArcBool, MsgSender, PosLLH}; +use anyhow::Context; +use chrono::{DateTime, Utc}; +use crossbeam::channel; +use crossbeam::channel::Sender; +use crossbeam::channel::TryRecvError; +use curl::easy::{Easy, HttpVersion, List, ReadError}; +use log::error; +use std::cell::RefCell; +use std::io::Write; +use std::rc::Rc; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, SystemTime}; +use std::{iter, thread}; +use strum_macros::EnumString; + +#[derive(Debug, Default)] +pub struct NtripState { + pub(crate) connected_thd: Option>, + pub(crate) options: NtripOptions, + pub(crate) is_running: ArcBool, + last_data: Arc>, +} + +#[derive(Debug, Default, Copy, Clone)] +struct LastData { + lat: f64, + lon: f64, + alt: f64, +} + +#[derive(Debug, Default, Clone)] +pub enum PositionMode { + #[default] + Dynamic, + Static { + lat: f64, + lon: f64, + alt: f64, + }, +} + +#[derive(Debug, Clone, EnumString)] +pub enum OutputType { + RTCM, + SBP, +} + +#[derive(Debug, Default, Clone)] +pub struct NtripOptions { + pub(crate) url: String, + pub(crate) username: Option, + pub(crate) password: Option, + pub(crate) nmea_period: u64, + pub(crate) pos_mode: PositionMode, + pub(crate) client_id: String, + pub(crate) output_type: Option, +} + +impl NtripOptions { + pub fn new( + url: String, + username: String, + password: String, + pos_mode: Option<(f64, f64, f64)>, + nmea_period: u64, + output_type: &str, + ) -> Self { + let pos_mode = pos_mode + .map(|(lat, lon, alt)| PositionMode::Static { lat, lon, alt }) + .unwrap_or(PositionMode::Dynamic); + + let username = Some(username).filter(|s| !s.is_empty()); + let password = Some(password).filter(|s| !s.is_empty()); + NtripOptions { + url, + username, + password, + pos_mode, + nmea_period, + client_id: "00000000-0000-0000-0000-000000000000".to_string(), + output_type: OutputType::from_str(output_type).ok(), + } + } +} + +#[derive(Debug, Clone, Copy, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +enum Message { + Gga { lat: f64, lon: f64, height: f64 }, +} + +fn build_gga(opts: &NtripOptions, last_data: &Arc>) -> Command { + let (lat, lon, height) = match opts.pos_mode { + PositionMode::Dynamic => { + let guard = last_data.lock().unwrap(); + (guard.lat, guard.lon, guard.alt) + } + PositionMode::Static { lat, lon, alt } => (lat, lon, alt), + }; + Command { + epoch: None, + after: 0, + crc: None, + message: Message::Gga { lat, lon, height }, + } +} + +#[derive(Debug, Clone, Copy, serde::Deserialize)] +struct Command { + #[serde(default = "default_after")] + after: u64, + epoch: Option, + crc: Option, + #[serde(flatten)] + message: Message, +} + +const fn default_after() -> u64 { + 10 +} + +impl Command { + fn to_bytes(self) -> Vec { + self.to_string().into_bytes() + } +} + +impl std::fmt::Display for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let now = self.epoch.map_or_else(SystemTime::now, |e| { + SystemTime::UNIX_EPOCH + Duration::from_secs(e) + }); + let message = self.message.format(now.into()); + let checksum = self.crc.unwrap_or_else(|| checksum(message.as_bytes())); + write!(f, "{message}*{checksum:X}") + } +} + +fn checksum(buf: &[u8]) -> u8 { + let mut sum = 0; + for c in &buf[1..] { + sum ^= c; + } + sum +} + +impl Message { + fn format(&self, time: DateTime) -> String { + match *self { + Message::Gga { lat, lon, height } => { + let time = time.format("%H%M%S.00"); + + let latn = ((lat * 1e8).round() / 1e8).abs(); + let lonn = ((lon * 1e8).round() / 1e8).abs(); + + let lat_deg = latn as u16; + let lon_deg = lonn as u16; + + let lat_min = (latn - (lat_deg as f64)) * 60.0; + let lon_min = (lonn - (lon_deg as f64)) * 60.0; + + let lat_dir = if lat < 0.0 { 'S' } else { 'N' }; + let lon_dir = if lon < 0.0 { 'W' } else { 'E' }; + + format!( + "$GPGGA,{},{:02}{:010.7},{},{:03}{:010.7},{},4,12,1.3,{:.2},M,0.0,M,1.7,0078", + time, lat_deg, lat_min, lat_dir, lon_deg, lon_min, lon_dir, height + ) + } + } + } +} + +fn get_commands( + opt: NtripOptions, + last_data: Arc>, +) -> anyhow::Result + Send>> { + if opt.nmea_period == 0 { + return Ok(Box::new(iter::empty())); + } + let first = build_gga(&opt, &last_data); + let rest = iter::repeat(Command { + after: opt.nmea_period, + ..first + }); + Ok(Box::new(iter::once(first).chain(rest))) +} + +#[derive(Default)] +struct Progress { + ul_tot: f64, + dl_tot: f64, + ul_tot_old: f64, + dl_tot_old: f64, +} + +impl Progress { + /// Fetch changed download and modify last changed + pub fn tick_dl(&mut self) -> f64 { + let diff = self.dl_tot - self.dl_tot_old; + self.dl_tot_old = self.dl_tot; + diff + } + + /// Fetch changed upload and modify last changed + pub fn tick_ul(&mut self) -> f64 { + let diff = self.ul_tot - self.ul_tot_old; + self.ul_tot_old = self.ul_tot; + diff + } +} + +fn main( + mut heartbeat: Heartbeat, + opt: NtripOptions, + last_data: Arc>, + is_running: ArcBool, + rtcm_tx: Sender>, +) -> anyhow::Result<()> { + let mut curl = Easy::new(); + let mut headers = List::new(); + headers.append("Transfer-Encoding:")?; + headers.append("Ntrip-Version: Ntrip/2.0")?; + headers.append(&format!("X-SwiftNav-Client-Id: {}", opt.client_id))?; + + let gga = build_gga(&opt, &last_data); + headers.append(&format!("Ntrip-GGA: {gga}"))?; + + curl.http_headers(headers)?; + curl.useragent("NTRIP ntrip-client/1.0")?; + curl.url(&opt.url)?; + curl.progress(true)?; + curl.put(true)?; + curl.custom_request("GET")?; + curl.http_version(HttpVersion::Any)?; + curl.http_09_allowed(true)?; + + if let Some(username) = &opt.username { + curl.username(username)?; + } + + if let Some(password) = &opt.password { + curl.password(password)?; + } + let (tx, rx) = channel::bounded::>(1); + let transfer = Rc::new(RefCell::new(curl.transfer())); + + let progress = Arc::new(Mutex::new(Progress::default())); + let progress_clone = progress.clone(); + let progress_thd = thread::spawn({ + let running = is_running.clone(); + move || loop { + { + if !running.get() { + return false; + } + } + { + let mut progress = progress_clone.lock().unwrap(); + heartbeat.set_ntrip_ul(progress.tick_ul()); + heartbeat.set_ntrip_dl(progress.tick_dl()); + } + thread::park_timeout(Duration::from_secs(1)) + } + }); + + transfer.borrow_mut().progress_function({ + let rx = ℞ + let transfer = Rc::clone(&transfer); + move |_dlnow, dltot, _ulnow, ultot| { + { + if !is_running.get() { + return false; + } + } + { + let mut progress = progress.lock().unwrap(); + progress.ul_tot = ultot; + progress.dl_tot = dltot; + } + if !rx.is_empty() { + if let Err(e) = transfer.borrow().unpause_read() { + error!("ntrip unpause error: {e}"); + return false; + } + } + true + } + })?; + + transfer.borrow_mut().write_function(move |data| { + if let Err(e) = rtcm_tx.send(data.to_owned()) { + error!("ntrip write error: {e}"); + return Ok(0); + } + Ok(data.len()) + })?; + transfer.borrow_mut().read_function(|mut data: &mut [u8]| { + let mut bytes = match rx.try_recv() { + Ok(bytes) => bytes, + Err(TryRecvError::Empty) => return Err(ReadError::Pause), + Err(TryRecvError::Disconnected) => return Err(ReadError::Abort), + }; + bytes.extend_from_slice(b"\r\n"); + if let Err(e) = data.write_all(&bytes) { + error!("ntrip read error: {e}"); + return Err(ReadError::Abort); + } + Ok(bytes.len()) + })?; + + let commands = get_commands(opt.clone(), last_data)?; + let handle = thread::spawn(move || { + for cmd in commands { + if cmd.after > 0 { + // need to unpark thread (?) + thread::park_timeout(Duration::from_secs(cmd.after)); + } + if tx.send(cmd.to_bytes()).is_err() { + break; + } + } + Ok(()) + }); + + transfer + .borrow() + .perform() + .context("ntrip curl perform errored")?; + if !handle.is_finished() { + Ok(()) + } else { + // an error stopped the thread early + progress_thd.join().expect("could not join progress thread"); + handle.join().expect("could not join on handle thread") + } +} + +impl NtripState { + pub fn connect( + &mut self, + msg_sender: MsgSender, + mut heartbeat: Heartbeat, + options: NtripOptions, + ) { + if self.connected_thd.is_some() && heartbeat.get_ntrip_connected() { + // is already connected + return; + } + + self.options = options.clone(); + let last_data = self.last_data.clone(); + self.is_running.set(true); + heartbeat.set_ntrip_connected(true); + let thd = thread::spawn({ + let running = self.is_running.clone(); + move || { + let (conv_tx, conv_rx) = channel::unbounded::>(); + let output_type = options.output_type.clone().unwrap_or(OutputType::RTCM); + let mut output_converter = MessageConverter::new(conv_rx, output_type); + if let Err(e) = output_converter.start(msg_sender).and(main( + heartbeat.clone(), + options, + last_data, + running.clone(), + conv_tx, + )) { + error!("{e}"); + } + running.set(false); + heartbeat.set_ntrip_connected(false); + } + }); + + self.connected_thd = Some(thd); + } + + pub fn disconnect(&mut self) { + self.is_running.set(false); + if let Some(thd) = self.connected_thd.take() { + let _ = thd.join(); + } + } + + /// Update data required for dynamic mode. + /// Currently used for position data, potentially epoch in the future. + pub fn set_last_data(&mut self, val: PosLLH) { + let fields = val.fields(); + let mut guard = self.last_data.lock().unwrap(); + guard.lat = fields.lat; + guard.lon = fields.lon; + guard.alt = fields.height; + } +} diff --git a/console_backend/src/types.rs b/console_backend/src/types.rs index 2a7e1092b..cb6393094 100644 --- a/console_backend/src/types.rs +++ b/console_backend/src/types.rs @@ -52,9 +52,11 @@ use sbp::messages::{ piksi::{Latency, MsgSpecan, MsgSpecanDep, MsgUartState, MsgUartStateDepa, Period}, ConcreteMessage, }; -use sbp::{Sbp, SbpEncoder, SbpMessage}; +use sbp::{Sbp, SbpMessage}; use serialport::FlowControl as SPFlowControl; +use std::fmt::Formatter; use std::io; +use std::io::Write; use std::{ cmp::{Eq, PartialEq}, collections::HashMap, @@ -68,13 +70,20 @@ use std::{ Arc, Mutex, }, }; + pub type Error = anyhow::Error; pub type Result = anyhow::Result; pub type UtcDateTime = DateTime; -/// Sends Sbp messages to the connected device +/// Sends messages to the connected device pub struct MsgSender { - inner: Arc>>>, + inner: Arc>>, +} + +impl Debug for MsgSender { + fn fmt(&self, _f: &mut Formatter<'_>) -> fmt::Result { + Ok(()) + } } impl MsgSender { @@ -82,12 +91,9 @@ impl MsgSender { const SENDER_ID: u16 = 42; const LOCK_FAILURE: &'static str = "failed to aquire sender lock"; - pub fn new(writer: W) -> Self - where - W: io::Write + Send + 'static, - { + pub fn new(writer: W) -> Self { Self { - inner: Arc::new(Mutex::new(SbpEncoder::new(Box::new(writer)))), + inner: Arc::new(Mutex::new(Box::new(writer))), } } @@ -97,8 +103,17 @@ impl MsgSender { msg.set_sender_id(Self::SENDER_ID); } let mut framed = self.inner.lock().expect(Self::LOCK_FAILURE); - framed.send(&msg).context("while sending a message")?; - Ok(()) + Ok(framed.write_all(&sbp::to_vec(&msg).context("while serializing into bytes")?)?) + } +} + +impl Write for MsgSender { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.inner.lock().expect(MsgSender::LOCK_FAILURE).write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.lock().expect(MsgSender::LOCK_FAILURE).flush() } } diff --git a/console_backend/src/utils/mod.rs b/console_backend/src/utils/mod.rs index 01dd620ac..b703cabe1 100644 --- a/console_backend/src/utils/mod.rs +++ b/console_backend/src/utils/mod.rs @@ -19,6 +19,7 @@ use std::collections::HashMap; use std::ops::Index; +use std::path::{Path, PathBuf}; use capnp::message::Builder; use capnp::message::HeapAllocator; @@ -38,6 +39,46 @@ use crate::types::SignalCodes; pub mod date_conv; pub mod formatters; +pub fn app_dir() -> crate::types::Result { + std::env::current_exe()? + .parent() + .ok_or(anyhow::format_err!("no parent directory")) + .map(Path::to_path_buf) +} + +/// Returns directory to packaged python, or workspace when ran locally in dev environment +/// This is used to locate rtcm3tosbp +pub fn pythonhome_dir() -> crate::types::Result { + let app_dir = app_dir()?; + // If dev environment, hard code check to py311 path "${WORKSPACE}\\py311" + // Mac and Linux both share python3 in "${WORKSPACE}/py311/bin" + let py311 = if cfg!(target_os = "windows") { + Some(app_dir.as_path()) + } else { + app_dir.parent() + }; + if let Some(py311) = py311 { + // if we are in the "${WORKSPACE}/py311" directory, + // we are in dev environment, move up one folder. + if py311.file_name().filter(|&x| x.eq("py311")).is_some() { + let workspace = py311 + .parent() + .ok_or(anyhow::format_err!("no workspace found?")); + return workspace.map(Path::to_path_buf); + } + // if compiled on mac, exe should be in "Swift Console.app/MacOS/Swift Console" + // app_dir gives "Swift Console.app/MacOS" + // returns "Swift Console.app/Resources/lib" + if cfg!(target_os = "macos") { + let resources = py311.join("Resources/lib"); + if resources.exists() { + return Ok(py311.join("Resources")); + } + } + } + Ok(app_dir) +} + /// Formats DOPS field into string, used in SolutionPositionTab pub fn dops_into_string(field: u16) -> String { format!("{:.1}", field as f64 * DILUTION_OF_PRECISION_UNITS) diff --git a/resources/AdvancedTab.qml b/resources/AdvancedTab.qml index b77553255..1fe6438cd 100644 --- a/resources/AdvancedTab.qml +++ b/resources/AdvancedTab.qml @@ -30,7 +30,7 @@ import QtQuick.Layouts MainTab { id: advancedTab - subTabNames: ["System Monitor", "IMU", "Magnetometer", "Networking", "Spectrum Analyzer", "INS"] + subTabNames: Globals.enableNtrip ? ["System Monitor", "IMU", "Magnetometer", "Networking", "Spectrum Analyzer", "INS", "NTRIP"] : ["System Monitor", "IMU", "Magnetometer", "Networking", "Spectrum Analyzer", "INS"] curSubTabIndex: 0 StackLayout { @@ -56,5 +56,8 @@ MainTab { AdvancedTabComponents.AdvancedInsTab { } + + AdvancedTabComponents.NtripClientTab { + } } } diff --git a/resources/AdvancedTabComponents/NtripClientTab.qml b/resources/AdvancedTabComponents/NtripClientTab.qml new file mode 100644 index 000000000..96fa5d327 --- /dev/null +++ b/resources/AdvancedTabComponents/NtripClientTab.qml @@ -0,0 +1,231 @@ +import "../BaseComponents" +import "../Constants" +import QtCharts 2.15 +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import SwiftConsole 1.0 + +Item { + id: ntripClientTab + + property bool connected: false + property var floatValidator + property var intValidator + property var stringValidator + + RowLayout { + anchors.fill: parent + + ColumnLayout { + Repeater { + id: generalRepeater + + model: ["Url", "Username", "Password", "GGA Period"] + + RowLayout { + height: 30 + + Label { + text: modelData + ": " + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + } + + TextField { + width: 400 + Layout.fillWidth: true + text: { + if (modelData == "Url") + return "na.skylark.swiftnav.com:2101"; + if (modelData == "GGA Period") + return "10"; + return ""; + } + placeholderText: modelData + font.family: Constants.genericTable.fontFamily + font.pixelSize: Constants.largePixelSize + selectByMouse: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignRight + validator: { + if (modelData == "GGA Period") + return intValidator; + return stringValidator; + } + readOnly: connected + } + } + } + + Repeater { + id: positionRepeater + + model: ["Lat", "Lon", "Alt"] + + RowLayout { + height: 30 + visible: staticRadio.checked + + Label { + text: modelData + ": " + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + } + + TextField { + id: textField + + width: 400 + Layout.fillWidth: true + placeholderText: modelData + font.family: Constants.genericTable.fontFamily + font.pixelSize: Constants.largePixelSize + selectByMouse: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignRight + validator: floatValidator + text: { + if (modelData == "Lat") + return "37.77101999622968"; + if (modelData == "Lon") + return "-122.40315159140708"; + if (modelData == "Alt") + return "-5.549358852471994"; + return ""; + } + readOnly: connected + } + } + } + } + + ColumnLayout { + RadioButton { + checked: true + text: "Dynamic" + ToolTip.visible: hovered + ToolTip.text: "Allow automatically fetching position from device" + enabled: !connected + } + + RadioButton { + id: staticRadio + + text: "Static" + ToolTip.visible: hovered + ToolTip.text: "Allow user input position" + enabled: !connected + } + + ComboBox { + id: outputType + + editable: false + + model: ListModel { + ListElement { + text: "RTCM" + } + + ListElement { + text: "SBP" + } + } + } + } + + ColumnLayout { + Label { + id: inputErrorLabel + + visible: false + text: "" + font.family: Constants.genericTable.fontFamily + font.pixelSize: Constants.largePixelSize + color: "red" + } + + RowLayout { + SwiftButton { + invertColor: true + icon.width: 10 + icon.height: 10 + icon.source: Constants.icons.playPath + icon.color: Constants.materialGrey + ToolTip.visible: hovered + ToolTip.text: "Start" + enabled: !connected + onClicked: { + let url = generalRepeater.itemAt(0).children[1].text; + if (!url) { + inputErrorLabel.text = "URL is not provided!"; + inputErrorLabel.visible = true; + return; + } + let username = generalRepeater.itemAt(1).children[1].text; + let password = generalRepeater.itemAt(2).children[1].text; + let ggaPeriod = generalRepeater.itemAt(3).children[1].text; + if (!ggaPeriod) { + inputErrorLabel.text = "GGA Period is not provided!"; + inputErrorLabel.visible = true; + return; + } + let lat = null; + let lon = null; + let alt = null; + if (staticRadio.checked) { + lat = positionRepeater.itemAt(0).children[1].text; + lon = positionRepeater.itemAt(1).children[1].text; + alt = positionRepeater.itemAt(2).children[1].text; + if (!lat || !lon || !alt) { + inputErrorLabel.text = "Position missing!"; + inputErrorLabel.visible = true; + return; + } + } + let output_type = outputType.currentText; + backend_request_broker.ntrip_connect(url, username, password, ggaPeriod, lat, lon, alt, output_type); + connected = true; + inputErrorLabel.visible = false; + } + } + + SwiftButton { + invertColor: true + icon.width: 10 + icon.height: 10 + icon.source: Constants.icons.pauseButtonUrl + icon.color: Constants.materialGrey + ToolTip.visible: hovered + ToolTip.text: "Stop" + enabled: connected + onClicked: { + backend_request_broker.ntrip_disconnect(); + connected = false; + inputErrorLabel.visible = false; + } + } + } + } + } + + NtripStatusData { + id: ntripStatusData + + signal ntrip_connected(bool connected) + + function setConnection(connected) { + ntripClientTab.connected = connected; + } + + Component.onCompleted: { + ntripStatusData.ntrip_connected.connect(setConnection); + } + } + + floatValidator: DoubleValidator { + } + + intValidator: IntValidator { + } + + stringValidator: RegularExpressionValidator { + } +} diff --git a/resources/Constants/Constants.qml b/resources/Constants/Constants.qml index 5217369e0..b2b585cd7 100644 --- a/resources/Constants/Constants.qml +++ b/resources/Constants/Constants.qml @@ -314,6 +314,7 @@ QtObject { readonly property string corrAgeLabel: "Correction Age:" readonly property string insLabel: "INS:" readonly property string antennaLabel: "Antenna:" + readonly property string ntripLabel: "Ntrip:" readonly property string defaultValue: "--" } diff --git a/resources/Constants/Globals.qml b/resources/Constants/Globals.qml index b7de9f4f5..012777154 100644 --- a/resources/Constants/Globals.qml +++ b/resources/Constants/Globals.qml @@ -28,6 +28,7 @@ QtObject { property bool useOpenGL: false property bool useAntiAliasing: true property bool showPrompts: true + property bool enableNtrip: false property int initialMainTabIndex: 0 // Tracking property int initialSubTabIndex: 0 // Signals property bool showCsvLog: false diff --git a/resources/SettingsTabComponents/SettingsPane.qml b/resources/SettingsTabComponents/SettingsPane.qml index e65216faf..6b41003b8 100644 --- a/resources/SettingsTabComponents/SettingsPane.qml +++ b/resources/SettingsTabComponents/SettingsPane.qml @@ -234,7 +234,7 @@ Rectangle { property string _fieldName: "description" visible: !!selectedRowField(_fieldName) - Layout.rowSpan: parents.rows - 8 + Layout.rowSpan: parent.rows - 8 Layout.columnSpan: 1 Layout.preferredWidth: parent.colWidthLabel Layout.preferredHeight: Math.max(1, parent.height - 8 * parent.smallRowHeight) @@ -245,7 +245,7 @@ Rectangle { property string _fieldName: "description" visible: !!selectedRowField(_fieldName) - Layout.rowSpan: parents.rows - 8 + Layout.rowSpan: parent.rows - 8 Layout.columnSpan: parent.columns - 1 Layout.preferredWidth: parent.colWidthField Layout.preferredHeight: Math.max(1, parent.height - 8 * parent.smallRowHeight) diff --git a/resources/StatusBar.qml b/resources/StatusBar.qml index 3a89364f8..d2e166ce0 100644 --- a/resources/StatusBar.qml +++ b/resources/StatusBar.qml @@ -37,6 +37,7 @@ Rectangle { property real dataRate: 0 property bool solidConnection: false property string title: "" + property string ntrip: "off" property int verticalPadding: Constants.statusBar.verticalPadding color: Constants.swiftOrange @@ -60,6 +61,7 @@ Rectangle { dataRate = statusBarData.data_rate; solidConnection = statusBarData.solid_connection; title = statusBarData.title; + ntrip = statusBarData.ntrip_display; } } } @@ -90,12 +92,16 @@ Rectangle { }, { "labelText": Constants.statusBar.antennaLabel, "valueText": antennaStatus + }, { + "labelText": Constants.statusBar.ntripLabel, + "valueText": ntrip }] RowLayout { spacing: Constants.statusBar.keyValueSpacing Label { + visible: modelData.valueText topPadding: Constants.statusBar.verticalPadding bottomPadding: Constants.statusBar.verticalPadding text: modelData.labelText @@ -106,6 +112,7 @@ Rectangle { Label { id: statusBarPos + visible: modelData.valueText Layout.minimumWidth: Constants.statusBar.valueMinimumWidth topPadding: Constants.statusBar.verticalPadding bottomPadding: Constants.statusBar.verticalPadding diff --git a/resources/console_resources.qrc b/resources/console_resources.qrc index b05f95174..b5de86bf7 100644 --- a/resources/console_resources.qrc +++ b/resources/console_resources.qrc @@ -42,6 +42,7 @@ AdvancedTabComponents/UnknownStatus.qml AdvancedTabComponents/WarningStatus.qml AdvancedTabComponents/OkStatus.qml + AdvancedTabComponents/NtripClientTab.qml BaselineTab.qml BaselineTabComponents/BaselinePlot.qml SettingsTab.qml diff --git a/src/main/resources/base/console_backend.capnp b/src/main/resources/base/console_backend.capnp index c48e252e0..79e392cc7 100644 --- a/src/main/resources/base/console_backend.capnp +++ b/src/main/resources/base/console_backend.capnp @@ -173,6 +173,9 @@ struct StatusBarStatus { dataRate @6: Float64; solidConnection @7: Bool; title @8: Text; + ntripConnected @9: Bool; + ntripDownload @10: Float64; + ntripUpload @11: Float64; } struct BaselinePlotStatus { @@ -454,6 +457,28 @@ struct SolutionProtectionLevel { hpl @2: UInt16; } +struct Position { + lat @0: Float64; + lon @1: Float64; + alt @2: Float64; +} + +struct NtripConnect { + url @0 :Text; + username @1 :Text; + password @2 :Text; + ggaPeriod @3 :UInt64; + position :union { + pos @4 :Position; + none @5 :Void; + } + outputType @6: Text; +} + +struct NtripDisconnect { + +} + struct Message { union { solutionVelocityStatus @0 :SolutionVelocityStatus; @@ -514,5 +539,7 @@ struct Message { loggingBarStartRecording @55 : LoggingBarStartRecording; loggingBarRecordingSize @56 : LoggingBarRecordingSize; solutionProtectionLevel @57: SolutionProtectionLevel; + ntripConnect @58 :NtripConnect; + ntripDisconnect @59 :NtripDisconnect; } } diff --git a/swiftnav_console/backend_request_broker.py b/swiftnav_console/backend_request_broker.py index 537d6394e..35b10e9ba 100644 --- a/swiftnav_console/backend_request_broker.py +++ b/swiftnav_console/backend_request_broker.py @@ -345,3 +345,40 @@ def switch_tab(self, tab_name) -> None: m.onTabChangeEvent.currentTab = tab_name buffer = m.to_bytes() self.endpoint.send_message(buffer) + + @Slot(str, str, str, int, QTKeys.QVARIANT, QTKeys.QVARIANT, QTKeys.QVARIANT, str) # type: ignore + def ntrip_connect( + self, + url: str, + username: str, + password: str, + gga_period: int, + lat: Optional[float], + lon: Optional[float], + alt: Optional[float], + output_type: str, + ) -> None: + Message = self.messages.Message + msg = self.messages.Message() + msg.ntripConnect = msg.init(Message.Union.NtripConnect) + msg.ntripConnect.url = url + msg.ntripConnect.username = username + msg.ntripConnect.password = password + msg.ntripConnect.ggaPeriod = gga_period + msg.ntripConnect.outputType = output_type + if lat is not None and lon is not None and alt is not None: + msg.ntripConnect.position.pos.lat = float(lat) + msg.ntripConnect.position.pos.lon = float(lon) + msg.ntripConnect.position.pos.alt = float(alt) + else: + msg.ntripConnect.position.none = None + buffer = msg.to_bytes() + self.endpoint.send_message(buffer) + + @Slot() # type: ignore + def ntrip_disconnect(self): + Message = self.messages.Message + msg = self.messages.Message() + msg.ntripDisconnect = msg.init(Message.Union.NtripDisconnect) + buffer = msg.to_bytes() + self.endpoint.send_message(buffer) diff --git a/swiftnav_console/constants.py b/swiftnav_console/constants.py index c2bc6d2b6..21589f732 100644 --- a/swiftnav_console/constants.py +++ b/swiftnav_console/constants.py @@ -158,6 +158,7 @@ class Keys(str, Enum): CONNECTION_MESSAGE = "CONNECTION_MESSAGE" NOTIFICATION = "NOTIFICATION" SOLUTION_LINE = "SOLUTION_LINE" + NTRIP_DISPLAY = "NTRIP_DISPLAY" class ConnectionState(str, Enum): diff --git a/swiftnav_console/main.py b/swiftnav_console/main.py index 8c9bb0a0d..e029cbd4a 100644 --- a/swiftnav_console/main.py +++ b/swiftnav_console/main.py @@ -63,6 +63,8 @@ from .backend_request_broker import BackendRequestBroker +from .ntrip_status import NtripStatusData + from .log_panel import ( log_panel_update, LogPanelData, @@ -488,6 +490,22 @@ def _process_message_buffer(self, buffer): data[Keys.SOLID_CONNECTION] = m.statusBarStatus.solidConnection data[Keys.TITLE] = m.statusBarStatus.title data[Keys.ANTENNA_STATUS] = m.statusBarStatus.antennaStatus + up = m.statusBarStatus.ntripUpload + down = m.statusBarStatus.ntripDownload + down_units = "B/s" + + if down >= 1000: + down /= 1000 + down = round(down, 1) + down_units = "KB/s" + + connected = m.statusBarStatus.ntripConnected + if connected: + data[Keys.NTRIP_DISPLAY] = f"{up}B/s ⬆ {down}{down_units} ⬇" + NtripStatusData.post_connected(True) + else: + data[Keys.NTRIP_DISPLAY] = "" + NtripStatusData.post_connected(False) StatusBarData.post_data_update(data) elif m.which == Message.Union.ConnectionStatus: data = connection_update() @@ -658,6 +676,8 @@ def handle_cli_arguments(args: argparse.Namespace, globals_: QObject): if args.enable_map: globals_.setProperty("enableMap", True) # type: ignore MAP_ENABLED[0] = True + if args.enable_ntrip: + globals_.setProperty("enableNtrip", True) # type: ignore try: if args.ssh_tunnel: ssh_tunnel.setup(args.ssh_tunnel, args.ssh_remote_bind_address) @@ -726,6 +746,7 @@ def main(passed_args: Optional[Tuple[str, ...]] = None) -> int: parser.add_argument("--show-csv-log", action="store_true") parser.add_argument("--height", type=int) parser.add_argument("--width", type=int) + parser.add_argument("--enable-ntrip", action="store_true") parser.add_argument("--qmldebug", action="store_true") if FEATURE_SSHTUNNEL: parser.add_argument("--ssh-tunnel", type=str, default=None) @@ -795,6 +816,7 @@ def main(passed_args: Optional[Tuple[str, ...]] = None) -> int: qmlRegisterType(SolutionTableEntries, "SwiftConsole", 1, 0, "SolutionTableEntries") # type: ignore qmlRegisterType(SolutionVelocityPoints, "SwiftConsole", 1, 0, "SolutionVelocityPoints") # type: ignore qmlRegisterType(StatusBarData, "SwiftConsole", 1, 0, "StatusBarData") # type: ignore + qmlRegisterType(NtripStatusData, "SwiftConsole", 1, 0, "NtripStatusData") # type: ignore qmlRegisterType(TrackingSignalsPoints, "SwiftConsole", 1, 0, "TrackingSignalsPoints") # type: ignore qmlRegisterType(TrackingSkyPlotPoints, "SwiftConsole", 1, 0, "TrackingSkyPlotPoints") # type: ignore qmlRegisterType(ObservationRemoteTableModel, "SwiftConsole", 1, 0, "ObservationRemoteTableModel") # type: ignore diff --git a/swiftnav_console/ntrip_status.py b/swiftnav_console/ntrip_status.py new file mode 100644 index 000000000..674dc443a --- /dev/null +++ b/swiftnav_console/ntrip_status.py @@ -0,0 +1,18 @@ +"""Ntrip Status QObjects. +""" + +from PySide6.QtCore import QObject, SignalInstance + + +class NtripStatusData(QObject): # pylint: disable=too-many-instance-attributes + _instance: "NtripStatusData" + ntrip_connected: SignalInstance + + def __init__(self): + super().__init__() + assert getattr(self.__class__, "_instance", None) is None + self.__class__._instance = self + + @classmethod + def post_connected(cls, connected: bool) -> None: + cls._instance.ntrip_connected.emit(connected) diff --git a/swiftnav_console/status_bar.py b/swiftnav_console/status_bar.py index 193a16073..d70b5daca 100644 --- a/swiftnav_console/status_bar.py +++ b/swiftnav_console/status_bar.py @@ -38,13 +38,14 @@ def status_bar_update() -> Dict[str, Any]: Keys.SOLID_CONNECTION: bool, Keys.TITLE: str, Keys.ANTENNA_STATUS: str, + Keys.NTRIP_DISPLAY: str, } STATUS_BAR: List[Dict[str, Any]] = [status_bar_update()] -class StatusBarData(QObject): # pylint: disable=too-many-instance-attributes +class StatusBarData(QObject): # pylint: disable=too-many-instance-attributes, too-many-public-methods _instance: "StatusBarData" _pos: str = "" _rtk: str = "" @@ -56,6 +57,7 @@ class StatusBarData(QObject): # pylint: disable=too-many-instance-attributes _title: str = "" _antenna_status: str = "" _data_updated = Signal() + _ntrip_display: str = "" status_bar: Dict[str, Any] = {} def __init__(self): @@ -147,6 +149,14 @@ def set_antenna_status(self, antenna_status: str) -> None: antenna_status = Property(str, get_antenna_status, set_antenna_status) + def get_ntrip_display(self) -> str: + return self._ntrip_display + + def set_ntrip_display(self, ntrip_display: str) -> None: + self._ntrip_display = ntrip_display + + ntrip_display = Property(str, get_ntrip_display, set_ntrip_display) + class StatusBarModel(QObject): # pylint: disable=too-few-public-methods @Slot(StatusBarData) # type: ignore @@ -160,4 +170,5 @@ def fill_data(self, cp: StatusBarData) -> StatusBarData: cp.set_solid_connection(cp.status_bar[Keys.SOLID_CONNECTION]) cp.set_title(cp.status_bar[Keys.TITLE]) cp.set_antenna_status(cp.status_bar[Keys.ANTENNA_STATUS]) + cp.set_ntrip_display(cp.status_bar[Keys.NTRIP_DISPLAY]) return cp