initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
1116
Cargo.lock
generated
Normal file
1116
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
Normal file
13
Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "gpsd-nmea"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
backoff = { version = "0.4.0", features = ["async-std"] }
|
||||||
|
clap = { version = "4.5.51", features = ["derive"] }
|
||||||
|
env_logger = "0.11.8"
|
||||||
|
futures = "0.3.31"
|
||||||
|
json = "0.12.4"
|
||||||
|
log = "0.4.28"
|
||||||
|
smol = "2.0.2"
|
||||||
219
src/main.rs
Normal file
219
src/main.rs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
use std::future;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use backoff::SystemClock;
|
||||||
|
use backoff::exponential::ExponentialBackoff;
|
||||||
|
use clap::{Parser, value_parser};
|
||||||
|
use env_logger;
|
||||||
|
use futures::future::{Either, select};
|
||||||
|
use futures::pin_mut;
|
||||||
|
use io::BufReader;
|
||||||
|
use json::{JsonValue, object};
|
||||||
|
use log::{debug, error, info};
|
||||||
|
use smol::channel::TrySendError;
|
||||||
|
use smol::io::AsyncReadExt;
|
||||||
|
use smol::net::{SocketAddr, TcpListener, TcpStream};
|
||||||
|
use smol::{Task, channel, io, prelude::*};
|
||||||
|
|
||||||
|
type NMEAChanMsg = Vec<u8>;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
version,
|
||||||
|
about = "An extremely simple program that connects to gpsd and streams NMEA strings to any connected clients"
|
||||||
|
)]
|
||||||
|
struct CmdArgs {
|
||||||
|
/// The gpsd host to connect to
|
||||||
|
#[arg(short = 'c', long, default_value = "[::1]:2947")]
|
||||||
|
host: SocketAddr,
|
||||||
|
/// The listen address to accept connections on
|
||||||
|
#[arg(long, default_value = "[::1]:2948")]
|
||||||
|
listen: SocketAddr,
|
||||||
|
/// The gpsd device to filter for (default all)
|
||||||
|
#[arg(short, long)]
|
||||||
|
device: Option<String>,
|
||||||
|
/// The NMEA 0183 messages to emit (default all)
|
||||||
|
#[arg(short, long="msg", num_args(1..), value_parser = clap::builder::ValueParser::from(|s: &str| {
|
||||||
|
let r: Result<[u8;5], _> = if s.len() != 5 || !s.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||||
|
Err(format!("`{s}` is not a valid NMEA0183 message type (expected 5 letters)"))
|
||||||
|
} else {
|
||||||
|
let upper = s.trim().to_ascii_uppercase();
|
||||||
|
Ok(upper.as_bytes().try_into().unwrap())
|
||||||
|
};
|
||||||
|
r
|
||||||
|
} ))]
|
||||||
|
msgs: Option<Vec<[u8; 5]>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Error {
|
||||||
|
NMEAParseError,
|
||||||
|
JsonParseError(json::JsonError),
|
||||||
|
UnknownGpsdResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct NMEAMsg {
|
||||||
|
id: [u8; 5],
|
||||||
|
msg: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&[u8]> for NMEAMsg {
|
||||||
|
type Error = Error;
|
||||||
|
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
|
||||||
|
if value.len() < 6 || value[0] != b'$' {
|
||||||
|
Err(Error::NMEAParseError)
|
||||||
|
} else {
|
||||||
|
Ok(NMEAMsg {
|
||||||
|
id: value[1..6].try_into().unwrap(),
|
||||||
|
msg: value.to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum GpsdResponse {
|
||||||
|
JsonResponse(json::JsonValue),
|
||||||
|
NMEAResponse(NMEAMsg),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TryFrom<&str> for GpsdResponse {
|
||||||
|
type Error = Error;
|
||||||
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||||
|
match value.as_bytes()[0] {
|
||||||
|
b'{' => Ok(GpsdResponse::JsonResponse(
|
||||||
|
json::parse(value).map_err(|e| Error::JsonParseError(e))?,
|
||||||
|
)),
|
||||||
|
b'$' => Ok(GpsdResponse::NMEAResponse(value.as_bytes().try_into()?)),
|
||||||
|
_ => Err(Error::UnknownGpsdResponse),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_gpsd_watch_cmd(device: Option<String>) -> String {
|
||||||
|
let payload = object! {
|
||||||
|
enable: true,
|
||||||
|
nmea: true,
|
||||||
|
device: device,
|
||||||
|
};
|
||||||
|
format!("?WATCH={}\n", json::stringify(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn gpsd_client_loop(
|
||||||
|
chan: channel::Sender<NMEAChanMsg>,
|
||||||
|
dest: SocketAddr,
|
||||||
|
device: Option<String>,
|
||||||
|
msgs: Option<Vec<[u8; 5]>>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let watch_cmd = build_gpsd_watch_cmd(device);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let backoff = ExponentialBackoff::<SystemClock>::default();
|
||||||
|
let mut conn = backoff::future::retry_notify(
|
||||||
|
backoff,
|
||||||
|
|| async { Ok(TcpStream::connect(dest).await?) },
|
||||||
|
|e, dur: Duration| {
|
||||||
|
let dur_s = dur.as_secs();
|
||||||
|
error!("Error connecting to {dest} after {dur_s}s: {e}")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
info!("Connected to gpsd service at {}", dest);
|
||||||
|
conn.write(watch_cmd.as_bytes()).await?;
|
||||||
|
debug!("Sent command {}", watch_cmd);
|
||||||
|
let mut lines = BufReader::new(conn.boxed_reader()).lines();
|
||||||
|
while let Some(Ok(line)) = lines.next().await {
|
||||||
|
debug!("Got line `{:#?}", line);
|
||||||
|
let msg = line.as_str().try_into();
|
||||||
|
if let Ok(msg) = msg {
|
||||||
|
match msg {
|
||||||
|
GpsdResponse::JsonResponse(_) => continue,
|
||||||
|
GpsdResponse::NMEAResponse(mut n) => {
|
||||||
|
if let Some(msgs) = &msgs {
|
||||||
|
if !msgs.contains(&n.id) {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if !chan.is_closed() {
|
||||||
|
n.msg.push(b'\r');
|
||||||
|
n.msg.push(b'\n');
|
||||||
|
if let Err(e) = chan.try_send(n.msg)
|
||||||
|
&& !e.is_closed()
|
||||||
|
{
|
||||||
|
error!("Error sending to channel: {}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ptp_server_loop(
|
||||||
|
mut conn: TcpStream,
|
||||||
|
chan: channel::Receiver<NMEAChanMsg>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
debug!("Starting server loop for {}", conn.peer_addr()?);
|
||||||
|
let mut junk_buf = [0u8; 8192];
|
||||||
|
loop {
|
||||||
|
let msg_fut = chan.recv();
|
||||||
|
let read_fut = conn.read(&mut junk_buf);
|
||||||
|
|
||||||
|
pin_mut!(msg_fut, read_fut);
|
||||||
|
|
||||||
|
match select(msg_fut, read_fut).await {
|
||||||
|
Either::Left((Ok(msg), _)) => conn.write_all(&msg).await?,
|
||||||
|
|
||||||
|
Either::Left((Err(e), _)) => {
|
||||||
|
error!("Channel closed for {}: {}", conn.peer_addr()?, e);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Either::Right((Ok(n), _)) => {
|
||||||
|
if n == 0 {
|
||||||
|
info!("Connection closed by peer {}", conn.peer_addr()?);
|
||||||
|
return Ok(());
|
||||||
|
} else {
|
||||||
|
debug!("Discarded {} bytes from {}", n, conn.peer_addr()?);
|
||||||
|
// We don't process these bytes, just ignore them
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TCP read error
|
||||||
|
Either::Right((Err(e), _)) => {
|
||||||
|
error!("Error reading from {}: {}", conn.peer_addr()?, e);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> io::Result<()> {
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
let cli = CmdArgs::parse();
|
||||||
|
|
||||||
|
smol::block_on(async {
|
||||||
|
let listen_socket = TcpListener::bind(cli.listen).await?;
|
||||||
|
let addr_s = listen_socket.local_addr()?;
|
||||||
|
info!("Listening on {addr_s}");
|
||||||
|
let (nmea_chan_tx, nmea_chan_rx) = channel::unbounded::<NMEAChanMsg>();
|
||||||
|
|
||||||
|
smol::spawn(
|
||||||
|
async move { gpsd_client_loop(nmea_chan_tx, cli.host, cli.device, cli.msgs).await },
|
||||||
|
)
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
while let Ok((srv, addr)) = listen_socket.accept().await {
|
||||||
|
info!("Connection from {addr}");
|
||||||
|
let local_chan = nmea_chan_rx.clone();
|
||||||
|
smol::spawn(async move {
|
||||||
|
if let Err(e) = ptp_server_loop(srv, local_chan).await {
|
||||||
|
error!("Connection task error {}", e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user