Initial commit
This commit is contained in:
172
backend/src/ogn/aprs/client.rs
Normal file
172
backend/src/ogn/aprs/client.rs
Normal 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")))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
498
backend/src/ogn/aprs/conversion.rs
Normal file
498
backend/src/ogn/aprs/conversion.rs
Normal 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(®ex.captures("2D").unwrap(), "value")
|
||||
.is_some_and(|t| t == AircraftType::Balloon)
|
||||
);
|
||||
|
||||
assert!(
|
||||
get_aircraft_type_by_capture(®ex.captures("07").unwrap(), "value")
|
||||
.is_some_and(|t| t == AircraftType::Glider)
|
||||
);
|
||||
|
||||
assert!(get_aircraft_type_by_capture(®ex.captures("00").unwrap(), "XXX").is_none());
|
||||
assert!(get_aircraft_type_by_capture(®ex.captures("ZZZZ").unwrap(), "value").is_none());
|
||||
assert!(get_aircraft_type_by_capture(®ex.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(®ex.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(®ex.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));
|
||||
}
|
||||
}
|
||||
49
backend/src/ogn/aprs/status.rs
Normal file
49
backend/src/ogn/aprs/status.rs
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user