Initial commit

This commit is contained in:
2024-03-12 22:28:05 +01:00
commit 7711bcc220
40 changed files with 5137 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
use std::{
collections::HashMap,
io::Error,
};
use log::{debug, error};
use serde::Deserialize;
use tokio::io::{AsyncBufReadExt, BufReader, BufWriter};
use tokio::{
io::AsyncWriteExt,
net::{TcpStream, ToSocketAddrs},
sync::mpsc::Sender,
};
use crate::{
ogn::{Aircraft, AircraftId},
time::get_current_timestamp,
};
use super::conversion::convert;
use super::status::Status;
/// Messages starting with a hashtag are comments (e.g. keep alive messages)
const IDENTIFIER_COMMENT: char = '#';
/// Messages starting with this sequence are connection details
const IDENTIFIER_TCP_PACKET: &str = "TCPIP*";
/// Approx. interval of keep alive messages to the server (in seconds)
const KEEPALIVE_INTERVAL_SECONDS: u64 = 60 * 10;
/// Keep alive message
const KEEPALIVE_MESSAGE: &[u8; 12] = b"#keep alive\n";
/// Configuration for connecting to an APRS server
#[derive(Deserialize)]
pub struct Config<A: ToSocketAddrs> {
/// Address to connect to, e.g. "aprs.example.com"
pub address: A,
/// User name for authentication
pub user_name: String,
/// Password for authentication
pub password: String,
/// Name of the application
pub client_id: String,
/// APRS filter that will be applied
pub filter: Option<String>,
}
/// Initiates a `TcpClient` that connects to an APRS server based on given `ClientConfig` and
/// transmits incoming aircraft states. Sends incoming APRS states via `status_tx`.
///
/// # Arguments
///
/// * `config` - Information on where to connect & login
/// * `status_tx` - A `Sender<String>` that will send incoming states from the server
/// * `line_received_tx` - A `Sender<u64>` that will send timestamps of incoming APRS
/// lines from the server
/// * `aircraft` - Mapping of `AircraftId` => `Aircraft`, necessary for conversion
///
/// # Returns
///
/// Future that will either result to () or Error when an error occurs.
///
/// # Examples
///
/// ```
/// use ogn::{Aircraft, AircraftId};
/// use std::collections::HashMap;
/// use tokio::{spawn, sync::mpsc::channel};
///
/// let config = aprs::ClientConfig { ... };
/// let (status_tx, status_rx) = channel(32);
/// let (line_received_tx, line_received_rx) = channel(32);
/// let aircraft: HashMap<AircraftId, Aircraft> = HashMap::new();
///
/// spawn(async move {
/// aprs::init(&config, &status_tx, &line_received_tx, &aircraft)
/// .await
/// .expect("Client failed");
/// });
///
/// while let Some(status) = status_rx.recv().await {
/// println!("Got status: {}", status);
/// }
/// ```
pub async fn init<A: ToSocketAddrs>(
config: &Config<A>,
status_tx: &Sender<Status>,
line_received_tx: &Sender<u64>,
aircraft: &HashMap<AircraftId, Aircraft>,
) -> Result<(), Error> {
let mut tcp_stream = TcpStream::connect(&config.address).await?;
let (mut read_half, mut write_half) = tcp_stream.split();
let mut tcp_stream_reader = BufReader::new(&mut read_half);
let mut tcp_stream_writer = BufWriter::new(&mut write_half);
/* Login to server */
let login_message = if let Some(filter) = &config.filter {
format!(
"user {} pass {} vers {} filter {}\n",
config.user_name, config.password, config.client_id, filter
)
} else {
format!(
"user {} pass {} vers {}\n",
config.user_name, config.password, config.client_id
)
};
let mut last_keep_alive_timestamp = get_current_timestamp();
tcp_stream_writer
.write_all(login_message.as_bytes())
.await?;
tcp_stream_writer.flush().await?;
loop {
let mut line = String::new();
match tcp_stream_reader.read_line(&mut line).await {
Ok(0) => {
debug!("Connection closed");
return Ok(());
}
Ok(_) => (),
Err(e) => {
/* This may happen */
error!("Error while reading line: {e}");
continue;
}
};
let current_timestamp = get_current_timestamp();
debug!("Got line: '{line}'");
line_received_tx
.send(current_timestamp)
.await
.or(Err(Error::other("Could not send line received timestamp")))?;
/* APRS server sends a keep alive ever 20 - 30 seconds. As we don't want to worry about
* *another* async interval shit, we just check if the last keep alive was 10 - 11 minutes
* ago and, if so, send a new one. We won't run into a timeout if we're 30 seconds late,
* so KISS FTW. */
if current_timestamp - last_keep_alive_timestamp >= KEEPALIVE_INTERVAL_SECONDS {
last_keep_alive_timestamp = current_timestamp;
tcp_stream_writer.write_all(KEEPALIVE_MESSAGE).await?;
tcp_stream_writer.flush().await?;
debug!("Sent keep alive");
}
if line.starts_with(IDENTIFIER_COMMENT) || line.contains(IDENTIFIER_TCP_PACKET) {
continue;
}
if let Some(status) = convert(&line, aircraft) {
if !status.aircraft.visible {
debug!("Got message for non-visible aircraft. Discard.");
continue;
}
debug!("Passing message for aircraft '{}'", status.aircraft.id);
status_tx
.send(status)
.await
.or(Err(Error::other("Could not send status")))?;
}
}
}

View File

@@ -0,0 +1,498 @@
use std::{collections::HashMap, sync::LazyLock};
use log::debug;
use regex::{Captures, Regex};
use crate::{
ogn::{Aircraft, AircraftId, AircraftType},
position::Position,
time::get_current_timestamp,
};
use super::status::Status;
/// Regex pattern to extract data from valid APRS messages
///
/// # Notes
///
/// At the part of "idXXYYYYYY", "XX" must not be 40 or higher!
/// This is due to the fact that this 2-digit hex number contains the tracking-information as
/// _binary_ in the form of _0bSTxxxxxx_ and if _S_ = _1_ or _T_ = _1_, we should discard the
/// message. So all "allowed" values are in the range of _0b00000000_ - _0b00111111_, or in hex:
/// _0x00_ - _0x3f_, therefore we can discard all messages not in this range.
///
/// see: [dbursem/ogn-client-php](https://github.com/dbursem/ogn-client-php/blob/master/lib/OGNClient.php#L87)
const LINE_PATTERN: &str = r"h(?<latitude>[0-9.]+[NS])[/\\]?.(?<longitude>[0-9.]+[WE]).(?:(?<course>\d{3})/(?<speed>\d{3})/A=(?<altitude>\d+))?.*?id(?<type>[0-3]{1}[A-Fa-f0-9]{1})(?<id>[A-Za-z0-9]+)(?: (?<verticalSpeed>[-+0-9]+)fpm)?(?: (?<turnRate>[-+.0-9]+)rot)?";
/// Factor to convert knots to km/h
const FACTOR_KNOTS_TO_KM_H: f32 = 1.852;
/// Factor to convert ft to m
const FACTOR_FT_TO_M: f32 = 0.3048;
/// Factor to convert ft/min to m/s
const FACTOR_FT_MIN_TO_M_SEC: f32 = 0.00508;
/// Factor to convert "turns/2min" to "turns/min"
const FACTOR_TURNS_TWO_MIN_TO_TURNS_MIN: f32 = 0.5;
static LINE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(LINE_PATTERN).unwrap());
/// Tries converting an APRS line into a `Status`
///
/// # Arguments
///
/// * `line` - The APRS line of the APRS server
/// * `aircraft` - Mapping of `AircraftId` => `Aircraft`, necessary for conversion
///
/// # Examples
///
/// ```
/// use ogn::{Aircraft, AircraftId}
/// use std::collections::HashMap;
///
/// let aircraft = Aircraft {
/// id: String::from("AB1234"),
/// call_sign: String::from("G1"),
/// registration: String::from("D-6507"),
/// aircraft_type: String::from("ASK-21"),
/// visible: true,
/// };
///
/// let mapping = HashMap::from([(aircraft.id.clone(), aircraft.clone())]);
///
/// let line = "FLRDDE626>APRS,qAS,EGHL:/074548h5111.32N/00102.04W'086/007/A=000607 id0AAB1234 -019fpm +0.0rot 5.5dB 3e -4.3kHz";
///
/// let result = convert(line, &mapping);
/// assert!(result.is_some());
/// assert_eq!(result.unwrap().aircraft.id, aircraft.id);
/// ```
pub fn convert(line: &str, aircraft: &HashMap<AircraftId, Aircraft>) -> Option<Status> {
let Some(captures) = LINE_REGEX.captures(line) else {
debug!("Line not parseable");
return None;
};
let id = captures.name("id")?.as_str();
let aircraft = if let Some(a) = aircraft.get(id) {
if a.model.is_some() {
a.clone()
} else {
let model = get_aircraft_type_by_capture(&captures, "type")
.map(|t| t.get_name())
.map(String::from);
a.with_model(model)
}
} else {
debug!("Unknown aircraft id '{id}'");
let model = get_aircraft_type_by_capture(&captures, "type")
.map(|t| t.get_name())
.map(String::from);
Aircraft {
id: String::from(id),
call_sign: None,
registration: None,
model,
visible: true,
}
};
let status = Status {
aircraft,
position: Position {
latitude: capture_as_coordinate_value(&captures, "latitude")?,
longitude: capture_as_coordinate_value(&captures, "longitude")?,
},
speed: capture_as_u16(&captures, "speed", FACTOR_KNOTS_TO_KM_H),
vertical_speed: capture_as_f32(&captures, "verticalSpeed", FACTOR_FT_MIN_TO_M_SEC),
altitude: capture_as_u16(&captures, "altitude", FACTOR_FT_TO_M),
turn_rate: capture_as_f32(&captures, "turnRate", FACTOR_TURNS_TWO_MIN_TO_TURNS_MIN),
course: capture_as_u16(&captures, "course", 1.0),
time_stamp: get_current_timestamp(),
};
Some(status)
}
/// Tries converting a `Captures` value to `f32` and multiply it to a `conversion_factor`
///
/// # Arguments
///
/// * `captures` - The regex `Captures` to look up
/// * `name` - Name of the captured value that should be converted
/// * `conversion_factor` - The factor that the value should be multiplied with
///
/// # Examples
///
/// ```
/// let captures = Regex::new(r"(?<value>[\d.]+)")
/// .unwrap()
/// .captures("12.34")
/// .unwrap();
///
/// assert!(capture_as_f32(&captures, "value", 1.0).is_some_and(|f| f == 12.34));
/// ```
fn capture_as_f32(captures: &Captures, name: &str, conversion_factor: f32) -> Option<f32> {
let string_value = captures.name(name)?.as_str();
let value = string_value.parse::<f32>().ok()?;
Some(value * conversion_factor)
}
/// Tries converting a `Captures` value to `u16` and multiply it to a `conversion_factor`
///
/// # Arguments
///
/// * `captures` - The regex `Captures` to look up
/// * `name` - Name of the captured value that should be converted
/// * `conversion_factor` - The factor that the value should be multiplied with
///
/// # Examples
///
/// ```
/// let captures = Regex::new(r"(?<value>\d+)")
/// .unwrap()
/// .captures("1234")
/// .unwrap();
///
/// assert!(capture_as_u16(&captures, "value", 1.0).is_some_and(|f| f == 1234));
/// ```
/// # Notes
///
/// Returns `None` if _value_ * `conversion_factor` would under- / overflow `u16` ranges
fn capture_as_u16(captures: &Captures, name: &str, conversion_factor: f32) -> Option<u16> {
let string_value = captures.name(name)?.as_str();
let value = string_value.parse::<u16>().ok()?;
let converted_value = f32::from(value) * conversion_factor;
if converted_value < f32::from(u16::MIN) || converted_value > f32::from(u16::MAX) {
return None;
}
/* We check for range and also sign. */
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
Some(converted_value as u16)
}
/// Tries converting a `Captures` APRS coordinate value to a latitude / longitude value as `f32`
///
/// # Arguments
///
/// * `captures` - The regex `Captures` to look up
/// * `name` - Name of the captured value that should be converted
///
/// # Examples
///
/// ```
/// let captures = Regex::new(r"(?<value>.+)")
/// .unwrap()
/// .captures("1029.35S")
/// .unwrap();
///
/// assert!(capture_as_coordinate_value(&captures, "value").is_some_and(|f| f == -10.489166));
/// ```
fn capture_as_coordinate_value(captures: &Captures, name: &str) -> Option<f32> {
/* Latitude and longitude (by APRS-standard) are given as following: ddmm.mmD where d = "degree",
* m = "minute" and D = "direction".
* Notice that minutes are decimals, so 0.5 minutes equal 0 minutes, 30 secs.
* We'll separate degrees and minutes, so we can convert it to a "degree"-only value. */
let string_value = captures.name(name)?.as_str();
let aprs_value = string_value
.chars()
.filter(char::is_ascii_digit)
.collect::<String>()
.parse::<f32>()
.ok()?;
let orientation = string_value.chars().last()?; /* "N", "E", "S" or "W" */
let degrees = f32::floor(aprs_value / 1_0000.0); // Separating "dd" from "ddmmmm"
let minutes = f32::floor(aprs_value % 1_0000.0) // Separating "mmmm" from "ddmmmm"
/ 60.0 // because 60 minutes = 1 degree
/ 100.0; // because of the removed decimal separator
if (f64::from(degrees) + f64::from(minutes)) > f64::from(f32::MAX) {
/* Don't think that's possible but just to be sure */
return None;
}
let value = degrees + minutes;
if orientation == 'S' || orientation == 'W' {
Some(-value)
} else {
Some(value)
}
}
/// Tries extracting the aircraft type from a `Capture` of the first encoded id field
///
/// # Arguments
///
/// * `captures` - The regex `Captures` to look up
/// * `name` - Name of the captured value that should be converted
///
/// # Examples
///
/// ```
/// let capture = Regex::new(r"(?<value>.*)")
/// .unwrap()
/// .captures("2D")
/// .unwrap();
///
/// let aircraft_type = et_aircraft_type_by_capture(&capture, "value");
///
/// assert!(aircraft_typ.is_some());
/// assert!(aircraft_type.unwrap() == AircraftType::Balloon);
/// ```
///
/// # References
///
/// - [OGN Wiki](http://wiki.glidernet.org/wiki:ogn-flavoured-aprs#toc2)
fn get_aircraft_type_by_capture(captures: &Captures, name: &str) -> Option<AircraftType> {
let string_value = captures.name(name)?.as_str();
let value = u8::from_str_radix(string_value, 16).ok()?;
/* Aircraft id field is built like this: "id" + "XX" + "YYYYYY" where the last part is the
* aircraft identifier (that matches DDB). "XX" is a 2-digit hex value built like this:
* 0bSTttttaa where "S" indicates stealth mode (should *NEVER* be sent over OGN), "T" is
* no-tracking mode (should *NEVER* be parsed by above_me), "tttt" is the aircraft type
* and "aa" is the address type. As we only need "tttt", we shift the whole number two
* digits to the left and just care for the lowest four bytes. */
/* This should *NEVER* happen! */
assert_eq!(
value & 0b1000_0000,
0,
"Line to be parsed has stealth mode active"
);
/* This should *NEVER* happen! */
assert_eq!(
value & 0b0100_0000,
0,
"Line to be parsed has no-tracking mode active"
);
let aircraft_type_value = (value >> 2) & 0b1111;
AircraftType::from_aprs_u8(aircraft_type_value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_type_works() {
let regex = Regex::new(r"(?<value>.*)").unwrap();
assert!(
get_aircraft_type_by_capture(&regex.captures("2D").unwrap(), "value")
.is_some_and(|t| t == AircraftType::Balloon)
);
assert!(
get_aircraft_type_by_capture(&regex.captures("07").unwrap(), "value")
.is_some_and(|t| t == AircraftType::Glider)
);
assert!(get_aircraft_type_by_capture(&regex.captures("00").unwrap(), "XXX").is_none());
assert!(get_aircraft_type_by_capture(&regex.captures("ZZZZ").unwrap(), "value").is_none());
assert!(get_aircraft_type_by_capture(&regex.captures("00").unwrap(), "value").is_none());
}
#[test]
#[should_panic]
fn get_type_panics_on_stealth_mode() {
let regex = Regex::new(r"(?<value>.*)").unwrap();
get_aircraft_type_by_capture(&regex.captures("80").unwrap(), "value");
}
#[test]
#[should_panic]
fn get_type_panics_on_no_tracking_mode() {
let regex = Regex::new(r"(?<value>.*)").unwrap();
get_aircraft_type_by_capture(&regex.captures("40").unwrap(), "value");
}
#[test]
fn convert_works() {
let valid_aircraft = Aircraft {
id: String::from("AB1234"),
call_sign: None,
registration: None,
model: None,
visible: true,
};
let mapping = HashMap::from([(valid_aircraft.id.clone(), valid_aircraft.clone())]);
let data_set = &[
(
"FLRDDE626>APRS,qAS,EGHL:/074548h5111.32N/00102.04W'086/007/A=000607 id0AAB1234 -019fpm +0.0rot 5.5dB 3e -4.3kHz",
valid_aircraft.id.as_str(),
51.188667,
Some(12),
Some(-0.09652),
Some(185),
Some(0.0),
Some(86)
),
(
"FLRDDE626>APRS,qAS,EGHL:/074548h5111.32N\\00102.04W'086/007/A=000607 id0AAB1234 5.5dB 3e -4.3kHz",
valid_aircraft.id.as_str(),
51.188667,
Some(12),
None,
Some(185),
None,
Some(86)
),
(
"FLRDDE626>APRS,qAS,EGHL:/074548h5111.32N\\00102.04W' id0AAB1234 5.5dB 3e -4.3kHz",
valid_aircraft.id.as_str(),
51.188667,
None,
None,
None,
None,
None
),
(
"FLRDDE626>APRS,qAS,EGHL:/074548h5111.32N/00102.04W' id0AAB1234 -019fpm +0.0rot 5.5dB 3e -4.3kHz",
valid_aircraft.id.as_str(),
51.188667,
None,
Some(-0.09652),
None,
Some(0.0),
None
),
(
"FLRDDE626>APRS,qAS,EGHL:/074548h5111.32N/00102.04W'086/007/A=000607 id0AAB1234 +0.0rot 5.5dB 3e -4.3kHz",
valid_aircraft.id.as_str(),
51.188667,
Some(12),
None,
Some(185),
Some(0.0),
Some(86)
),
(
"FLRDDE626>APRS,qAS,EGHL:/074548h5111.32N/00102.04W' id0AAB1234 +0.0rot 5.5dB 3e -4.3kHz",
valid_aircraft.id.as_str(),
51.188667,
None,
None,
None,
Some(0.0),
None
),
];
for (line, aircraft_id, latitude, speed, vertical_speed, altitude, turn_rate, course) in
data_set
{
let result = convert(line, &mapping);
assert!(result.is_some());
let status = result.unwrap();
assert_eq!(&status.aircraft.id, aircraft_id);
assert_eq!(&status.position.latitude, latitude);
assert_eq!(&status.speed, speed);
assert_eq!(&status.vertical_speed, vertical_speed);
assert_eq!(&status.altitude, altitude);
assert_eq!(&status.turn_rate, turn_rate);
assert_eq!(&status.course, course);
assert!(status.time_stamp > 0);
}
}
#[test]
fn convert_works_with_unknown_aircraft() {
let mapping = HashMap::new();
let line = "FLRDDE626>APRS,qAS,EGHL:/074548h5111.32N/00102.04W'086/007/A=000607 id0AAB1234 -019fpm +0.0rot 5.5dB 3e -4.3kHz";
let result = convert(line, &mapping);
assert!(result.is_some());
let status = result.unwrap();
assert_eq!(status.aircraft.id, "AB1234");
assert!(status.aircraft.call_sign.is_none());
assert!(status.aircraft.registration.is_none());
assert!(status.aircraft.model.is_some_and(|v| v == "Tow plane"));
assert!(status.aircraft.visible);
}
#[test]
fn convert_ignores_stealth_mode() {
let mapping = HashMap::new();
let line = "FLRDDE626>APRS,qAS,EGHL:/074548h5111.32N/00102.04W'086/007/A=000607 id8AAB1234 -019fpm +0.0rot 5.5dB 3e -4.3kHz";
assert!(convert(line, &mapping).is_none());
}
#[test]
fn convert_ignores_no_tracking_mode() {
let mapping = HashMap::new();
let line = "FLRDDE626>APRS,qAS,EGHL:/074548h5111.32N/00102.04W'086/007/A=000607 id4AAB1234 -019fpm +0.0rot 5.5dB 3e -4.3kHz";
assert!(convert(line, &mapping).is_none());
}
#[test]
fn test_capture_as_f32_works() {
let captures = Regex::new(r"(?<value>[\d.]+)")
.unwrap()
.captures("12.34")
.unwrap();
assert!(capture_as_f32(&captures, "value", 1.0).is_some_and(|f| f == 12.34));
assert!(capture_as_f32(&captures, "value", 2.0).is_some_and(|f| f == 24.68));
}
#[test]
fn test_capture_as_u16_works() {
let captures = Regex::new(r"(?<value>\d+)")
.unwrap()
.captures("1234")
.unwrap();
assert!(capture_as_u16(&captures, "value", 1.0).is_some_and(|f| f == 1234));
assert!(capture_as_u16(&captures, "value", 2.0).is_some_and(|f| f == 2468));
}
#[test]
fn test_capture_as_u16_fails_on_out_of_range() {
let captures = Regex::new(r"(?<value>\d+)")
.unwrap()
.captures("1234")
.unwrap();
assert!(capture_as_u16(&captures, "value", 10.0).is_some());
assert!(capture_as_u16(&captures, "value", 100.0).is_none());
assert!(capture_as_u16(&captures, "value", -1.0).is_none());
}
#[test]
fn test_capture_as_coordinate_value_works() {
let captures = Regex::new(r"(?<value>.+)")
.unwrap()
.captures("5111.32N")
.unwrap();
assert!(capture_as_coordinate_value(&captures, "value").is_some_and(|f| f == 51.188667));
}
#[test]
fn test_capture_as_coordinate_value_works_on_negative() {
let captures = Regex::new(r"(?<value>.+)")
.unwrap()
.captures("1029.35S")
.unwrap();
assert!(capture_as_coordinate_value(&captures, "value").is_some_and(|f| f == -10.489166));
}
}

View File

@@ -0,0 +1,49 @@
use std::fmt::{Display, Formatter, Result};
use crate::{ogn::Aircraft, position::Position};
/// Representation of an aircraft status
pub struct Status {
/// Affected aircraft
pub aircraft: Aircraft,
/// Position of aircraft
pub position: Position,
/// Speed in _km/h_
pub speed: Option<u16>,
/// Vertical speed in _m/s_
pub vertical_speed: Option<f32>,
/// Altitude in _m_
pub altitude: Option<u16>,
/// Turn rate in _turns/min_
pub turn_rate: Option<f32>,
/// Course of aircraft
pub course: Option<u16>,
/// Timestamp of receiving status
pub time_stamp: u64,
}
impl Display for Status {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
write!(
f,
"[
Aircraft: {},
Position: {},
Speed: {:?},
Vertical speed: {:?},
Altitude: {:?},
Turn rate: {:?},
Course: {:?},
Timestamp: {}
]",
self.aircraft,
self.position,
self.speed,
self.vertical_speed,
self.altitude,
self.turn_rate,
self.course,
self.time_stamp
)
}
}