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

45
backend/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,45 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'above_me'",
"cargo": {
"args": [
"build",
"--bin=above_me",
"--package=above_me"
],
"filter": {
"name": "above_me",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'above_me'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=above_me",
"--package=above_me"
],
"filter": {
"name": "above_me",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}

2236
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
backend/Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "above_me"
version = "0.1.4"
edition = "2024"
authors = ["Laika Schmidt <laika.schmidt@magenta.de>"]
description = "This project contains the backend for above_me, a service that lets you see what aircraft are currently flying above you."
readme = "../README.md"
license = "MIT"
keywords = ["ogn", "openglidernet", "open-glider-net", "aircraft", "glider"]
exclude = ["Dockerfile", "target/"]
[dependencies]
axum = "0.8.7"
config = "0.15.19"
regex = { version = "1.12.2", features = ["std"] }
reqwest = "0.12"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
log = "0.4"
env_logger = "0.11.8"
[profile.release]
strip = true

17
backend/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM rust:latest AS build
WORKDIR /app
COPY src src
COPY Cargo.lock .
COPY Cargo.toml .
RUN cargo build --release
FROM rust:slim
WORKDIR /app
COPY --from=build /app/target/release/above_me .
CMD ["./above_me"]

10
backend/src/api/mod.rs Normal file
View File

@@ -0,0 +1,10 @@
pub use server::init;
pub use state::App;
mod routes {
pub mod aircraft;
pub mod overview;
}
mod server;
mod state;

View File

@@ -0,0 +1,84 @@
use axum::{
extract::{Path, State},
Json,
};
use serde::Serialize;
use crate::{
api::App,
ogn::{aprs::Status, Aircraft},
position::Position,
};
/// Handler for route _/r/:latitude/:longitude/:range_
///
/// Responds with a list of aircraft in the _:range_ around _:latitude_ and _:longitude_
pub async fn handler(
Path((latitude, longitude, range)): Path<(f32, f32, f32)>,
State(app): State<App>,
) -> Json<Response> {
/* Ensure range can be used as f32 */
let position = Position {
latitude,
longitude,
};
Json(Response {
latitude,
longitude,
range,
states: app.get_filtered_status_dtos(&position, range),
})
}
#[derive(Serialize)]
pub struct Response {
/// Equals given latitude parameter
latitude: f32,
/// Equals given longitude parameter
longitude: f32,
/// Equals given range parameter
range: f32,
/// The aircraft states that match the given parameters
states: Vec<StatusDto>,
}
/// Dto representation of an aircraft status, containing the distance to the
/// requested postion in km.
#[derive(Clone, Serialize)]
pub struct StatusDto {
/// 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,
/// Distance to given postion in km
pub distance: f32,
}
impl StatusDto {
pub fn from(status: &Status, distance: f32) -> Self {
Self {
aircraft: status.aircraft.clone(),
position: status.position.clone(),
speed: status.speed,
vertical_speed: status.vertical_speed,
altitude: status.altitude,
turn_rate: status.turn_rate,
course: status.course,
time_stamp: status.time_stamp,
distance,
}
}
}

View File

@@ -0,0 +1,10 @@
use axum::{extract::State, Json};
use crate::api::{App, state::Overview};
/// Handler for route _/status_
///
/// Responds with an overview of the currently stored states
pub async fn handler(State(app): State<App>) -> Json<Overview> {
Json(app.get_overview())
}

62
backend/src/api/server.rs Normal file
View File

@@ -0,0 +1,62 @@
use axum::{routing::get, Router};
use log::info;
use tokio::sync::oneshot;
use std::io::Error;
use tokio::{net::TcpListener, net::ToSocketAddrs};
use super::routes::{aircraft, overview};
use super::state::App;
/// Initializes a tcp server that serves our API
///
/// # Arguments
///
/// * `address` - The address that the server will bind to
/// * `app` - The `App` that the API will use for its data
/// * `shutdown_rx` - A shotgun `Receiver<()>` that will shut down the server gracefully when a
/// message is received.
///
/// # Returns
///
/// Future that will either result to () or Error when an error occurs.
///
/// # Examples
///
/// ```
/// use api::App;
/// use tokio::{spawn, sync::oneshot};
///
/// let address = "127.0.0.1:8080";
/// let (shutdown_tx, shutdown_rx) = oneshot::channel();
/// let app = App::create();
///
/// spawn(async move {
/// api::init(&address, app, shutdown_rx)
/// .await
/// .expect("API server failed");
/// });
///
/// // Shuts down API server
/// shutdown_tx.send(()).unwrap();
/// ```
pub async fn init<A: ToSocketAddrs>(
address: &A,
app: App,
shutdown_rx: oneshot::Receiver<()>,
) -> Result<(), Error> {
let app = Router::new()
.route("/r/{latitude}/{longitude}/{range}", get(aircraft::handler))
.route("/status", get(overview::handler))
.with_state(app);
let listener = TcpListener::bind(address).await?;
axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
info!("API received shutdown signal");
})
.await?;
Ok(())
}

347
backend/src/api/state.rs Normal file
View File

@@ -0,0 +1,347 @@
use std::{
collections::HashMap,
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex, MutexGuard,
},
};
use serde::Serialize;
use crate::{
aprs::Status,
position::{calculate_distance, Position},
time::get_current_timestamp,
};
use super::routes::aircraft::StatusDto;
const MAX_AGE_DIFF: u64 = 60 * 5; /* 5 minutes */
/// Our shared application state for the API
#[derive(Clone)]
pub struct App {
/// Reference to all currently stored states
states: Arc<Mutex<HashMap<String, Status>>>,
/// Timestamp of last APRS line received
last_aprs_update: Arc<AtomicU64>,
}
/// DTO for status overview
#[derive(Serialize)]
pub struct Overview {
/// Number of currently stored states
pub count: usize,
/// Timestamp of last status update, if states is not empty
pub last_status_update: Option<u64>,
/// Timestamp of last APRS update received
pub last_aprs_update: Option<u64>,
}
impl App {
/// Creates a new `App`
///
/// # Examples
///
/// ```
/// use api::App;
///
/// let app = App::create();
/// ```
pub fn create() -> App {
App {
states: Arc::new(Mutex::new(HashMap::new())),
last_aprs_update: Arc::new(AtomicU64::new(0)),
}
}
/// Returns the states in the `App` that match given filters as dtos.
///
/// # Arguments
/// * `position` - The position that should be searched for
/// * `range` - Range around given `position` that should be searched for.
///
/// # Returns
///
/// Returns dtos of the states within `range` around given `position`, sorted in ascending
/// oder by distance to `position`.
///
/// # Examples
///
/// * test `state::get_filtered_states_checks_age`
/// * test `state::get_filtered_states_checks_range`
/// * test `state::get_filtered_states_orders_correctly`
pub fn get_filtered_status_dtos(&self, position: &Position, range: f32) -> Vec<StatusDto> {
let mut states = self.states.lock().expect("Mutex was poisoned");
App::remove_outdated_states(&mut states);
let mut status_dtos = states
.values()
.map(|status| (status, calculate_distance(position, &status.position)))
.filter(|&(_, distance)| distance <= range)
.map(|(status, distance)| StatusDto::from(status, distance))
.collect::<Vec<StatusDto>>();
status_dtos.sort_unstable_by(|status_dto_1, status_dto_2| {
status_dto_1
.distance
.partial_cmp(&status_dto_2.distance)
.unwrap()
});
status_dtos
}
/// Stores / updates a new status in the `App`
///
/// # Arguments
///
/// * `status` - The status to store / update
///
/// # Examples
///
/// * test `state::get_filtered_states_checks_age`
/// * test `state::get_filtered_states_checks_range`
pub fn push_status(&self, new_status: Status) {
let mut states = self.states.lock().expect("Mutex was poisoned");
App::remove_outdated_states(&mut states);
states.insert(new_status.aircraft.id.clone(), new_status);
}
/// Updates timestamp of latest APRS update in the `App`
///
/// # Arguments
///
/// * `timestamp` - Timestamp of latest APRS update
///
/// # Examples
///
/// * test `state::get_overview_works`
pub fn push_last_aprs_update_timestamp(&self, timestamp: u64) {
self.last_aprs_update.store(timestamp, Ordering::Relaxed);
}
/// Returns an overview of the currently stored states
///
/// # Examples
///
/// * test `state::get_overview_works`
pub fn get_overview(&self) -> Overview {
/* Shortcut: As AtomicU64 seems to be the best choice for a shared timestamp value,
* we can't use an `Option` directly in the `App`. But as the timestamp is initialized
* with 0, we can just convert the 0 to `None`. */
let last_aprs_update = match self.last_aprs_update.load(Ordering::Relaxed) {
0 => None,
v => Some(v),
};
let mut states = self.states.lock().expect("Mutex was poisoned");
App::remove_outdated_states(&mut states);
Overview {
count: states.len(),
last_status_update: states.values().map(|s| s.time_stamp).max(),
last_aprs_update,
}
}
/// Removes outdated states (by max age)
///
/// # Arguments
///
/// * `states` - `MutexGuard` of states map
fn remove_outdated_states(states: &mut MutexGuard<HashMap<String, Status>>) {
let current_timestamp = get_current_timestamp();
let outdated_keys = states
.values()
.filter(|e| current_timestamp - e.time_stamp > MAX_AGE_DIFF)
.map(|e| e.aircraft.id.clone())
.collect::<Vec<String>>();
for key in outdated_keys {
states.remove(&key);
}
}
}
#[cfg(test)]
mod tests {
use crate::ogn::Aircraft;
use super::*;
#[test]
fn get_filtered_status_dtos_checks_age() {
let sut = App::create();
let current_timestamp = get_current_timestamp();
let outdated_timestamp = current_timestamp - MAX_AGE_DIFF - 1;
let position = Position {
longitude: 48.858222,
latitude: 2.2945,
};
sut.push_status(create_status(
String::from("AB1234"),
position.clone(),
current_timestamp,
));
sut.push_status(create_status(
String::from("CD5678"),
position.clone(),
outdated_timestamp,
));
let result = sut.get_filtered_status_dtos(&position, 1.0);
assert_eq!(result.len(), 1);
assert_eq!(result[0].aircraft.id, "AB1234");
}
#[test]
fn get_filtered_status_dtos_checks_range() {
let sut = App::create();
let current_timestamp = get_current_timestamp();
let position = Position {
latitude: 48.858222,
longitude: 2.2945,
};
sut.push_status(create_status(
String::from("AB1234"),
position.clone(),
current_timestamp,
));
sut.push_status(create_status(
String::from("CD5678"),
Position {
/* see position.rs -> 3.16 km */
latitude: 48.86055,
longitude: 2.3376,
},
current_timestamp,
));
sut.push_status(create_status(
String::from("EF9012"),
Position {
longitude: 48.84,
latitude: 2.2,
},
current_timestamp,
));
let result = sut.get_filtered_status_dtos(&position, 4.0);
assert_eq!(result.len(), 2);
assert!(result.iter().any(|s| s.aircraft.id == "AB1234"));
assert!(result.iter().any(|s| s.aircraft.id == "CD5678"));
}
#[test]
fn get_filtered_status_dtos_orders_correctly() {
let sut = App::create();
let current_timestamp = get_current_timestamp();
let position = Position {
latitude: 48.858222,
longitude: 2.2945,
};
sut.push_status(create_status(
String::from("CD5678"),
Position {
latitude: position.latitude + 0.0001,
longitude: position.longitude + 0.0001,
},
current_timestamp,
));
sut.push_status(create_status(
String::from("AB1234"),
Position {
latitude: position.latitude,
longitude: position.longitude,
},
current_timestamp,
));
sut.push_status(create_status(
String::from("EF9012"),
Position {
latitude: position.latitude + 0.0002,
longitude: position.longitude + 0.0002,
},
current_timestamp,
));
let result = sut.get_filtered_status_dtos(&position, 4.0);
assert_eq!(result.len(), 3);
assert_eq!(result[0].aircraft.id, "AB1234");
assert_eq!(result[1].aircraft.id, "CD5678");
assert_eq!(result[2].aircraft.id, "EF9012");
}
#[test]
fn get_overview_works() {
let sut = App::create();
let result_empty = sut.get_overview();
let current_timestamp = get_current_timestamp();
let position = Position {
latitude: 48.858222,
longitude: 2.2945,
};
sut.push_status(create_status(
String::from("AB1234"),
position.clone(),
current_timestamp - 50,
));
sut.push_status(create_status(
String::from("CD5678"),
position.clone(),
current_timestamp,
));
sut.push_last_aprs_update_timestamp(current_timestamp);
let result_filled = sut.get_overview();
assert_eq!(result_empty.count, 0);
assert_eq!(result_empty.last_status_update, None);
assert_eq!(result_empty.last_aprs_update, None);
assert_eq!(result_filled.count, 2);
assert_eq!(result_filled.last_status_update, Some(current_timestamp));
assert_eq!(result_filled.last_aprs_update, Some(current_timestamp));
}
fn create_status(aircraft_id: String, position: Position, time_stamp: u64) -> Status {
Status {
aircraft: Aircraft {
id: aircraft_id,
call_sign: None,
registration: None,
model: None,
visible: true,
},
position,
speed: None,
vertical_speed: None,
altitude: None,
turn_rate: None,
course: None,
time_stamp,
}
}
}

37
backend/src/config.rs Normal file
View File

@@ -0,0 +1,37 @@
use config::{ConfigError, Environment, File, FileFormat};
use serde::Deserialize;
use crate::aprs;
/// Name of the config file (".json" is added by the `config` crate automatically)
pub const PROJECT_CONFIG_FILE: &str = "../config";
pub const BACKEND_CONFIG_FILE: &str = "config";
pub const ENVIRONMENT_PREFIX: &str = "ABOVE_ME";
const ENVIRONMENT_SEPARATOR: &str = "__";
/// Representation of program configuration
#[derive(Deserialize)]
pub struct Config {
/// Config for connecting to the APRS server
pub aprs: aprs::Config<String>,
/// Url of the DDB server to fetch aircraft information
pub ddb_url: String,
/// Url that the API server should bind to
pub bind_to: String,
}
/// Tries loading configuration from config files or environment
///
/// # Examples
/// ```
/// let config = load_config().expect("Could not load config by file");
/// print!("Server will bind to: {}", config.bind_to);
/// ```
pub fn load() -> Result<Config, ConfigError> {
config::Config::builder()
.add_source(File::new(PROJECT_CONFIG_FILE, FileFormat::Json).required(false))
.add_source(File::new(BACKEND_CONFIG_FILE, FileFormat::Json).required(false))
.add_source(Environment::with_prefix(ENVIRONMENT_PREFIX).separator(ENVIRONMENT_SEPARATOR))
.build()?
.try_deserialize::<Config>()
}

97
backend/src/main.rs Normal file
View File

@@ -0,0 +1,97 @@
use crate::ogn::{aprs, ddb::fetch_aircraft};
use log::{error, info};
use tokio::{sync::oneshot, select, sync::mpsc, task::JoinSet};
mod api;
mod config;
mod ogn;
mod position;
mod time;
#[tokio::main]
async fn main() {
env_logger::init();
info!("Loading config...");
let config = match config::load() {
Ok(c) => {
info!("Loaded config successfully!");
c
}
Err(e) => {
error!("Could not load config: {e}");
return;
}
};
info!("Loading aircraft data...");
let aircraft = match fetch_aircraft(&config.ddb_url).await {
Ok(a) => {
info!("Loaded aircraft data successfully!");
a
}
Err(e) => {
error!("Could not fetch aircraft data: {e}");
return;
}
};
let mut join_set = JoinSet::new();
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let (status_tx, mut status_rx) = mpsc::channel(32);
let (line_received_tx, mut line_received_rx) = mpsc::channel(32);
let app = api::App::create();
let app_update = app.clone();
join_set.spawn(async move {
info!("Initializing API...");
if let Err(e) = api::init(&config.bind_to, app, shutdown_rx).await {
error!("API stopped with error: {e}");
} else {
info!("API stopped");
}
});
join_set.spawn(async move {
info!("Initializing APRS client...");
loop {
if let Err(e) = aprs::init(&config.aprs, &status_tx, &line_received_tx, &aircraft).await
{
error!("Client stopped with error: {e}");
break;
}
/* Server may disconnect us at some point. Just reconnect and carry on. */
info!("Client disconnected. Reconnecting...");
}
if shutdown_tx.send(()).is_err() {
error!("Could not send shutdown signal. You need to kill this program :(");
}
});
join_set.spawn(async move {
info!("Initializing updates from client to API...");
loop {
select! {
Some(status) = status_rx.recv() => {
app_update.push_status(status);
},
Some(timestamp) = line_received_rx.recv() => {
app_update.push_last_aprs_update_timestamp(timestamp);
},
else => break
}
}
info!("Updates from client to API stopped");
});
while (join_set.join_next().await).is_some() {}
info!("Shutdown");
}

146
backend/src/ogn/aircraft.rs Normal file
View File

@@ -0,0 +1,146 @@
use std::fmt::{Display, Formatter, Result};
use serde::Serialize;
/// Representing information about an aircraft.
#[derive(Clone, Serialize)]
pub struct Aircraft {
/// DDB id of the aircraft
#[serde(skip_serializing)]
pub id: String,
/// Call sign, e.g. "G1"
pub call_sign: Option<String>,
/// Registration, e.g. "D-6507"
pub registration: Option<String>,
/// Aircraft model type, e.g. "ASK-21"
pub model: Option<String>,
/// Should the aircraft be identified and tracked?
#[serde(skip_serializing)]
pub visible: bool,
}
impl Aircraft {
/// Clones `Aircraft` with a given `model` name
///
/// # Arguments
/// * `model` - The new model name that the resulting aircraft
/// should have
///
/// # Examples
///
/// ```
/// let aircraft = Aircraft {
/// id: String::from("AB1234"),
/// call_sign: String::from("G1"),
/// registration: String::from("D-6507"),
/// model: String::from(""),
/// visible: true,
/// };
///
/// let aircraft_with_model = aircraft.with_model(Some(String::new("ASK-21")));
///
/// assert!(aircraft_with_model.model.is_some());
/// assert_eq!(aircraft_with_model.model.unwrap(), "ASK-21");
/// ```
pub fn with_model(&self, model: Option<String>) -> Aircraft {
Aircraft {
id: self.id.clone(),
call_sign: self.call_sign.clone(),
registration: self.registration.clone(),
model,
visible: self.visible,
}
}
}
/// Representation of generic aicraft types.
#[derive(PartialEq)]
pub enum Type {
Glider,
Tow,
Helicopter,
SkyDiver,
DropPlane,
HangGlider,
Paraglider,
MotorAircraft,
Jet,
Balloon,
Blimp,
Unmanned,
Obstacle,
}
impl Type {
/// Tries getting aircaft type for the APRS aircraft type value
/// (encoded inside the aircraft id field).
///
/// # Arguments
///
/// * `id` - Aircraft type id
///
/// # Examples
/// ```
/// assert_eq!(Type::from_aprs_u8(15), Type::Obstacle);
/// assert_eq!(Type::from_aprs_u8(0), None);
/// ```
///
/// # References
/// - `aprs::get_aircraft_type_by_capture`
/// - [OGN Wiki](http://wiki.glidernet.org/wiki:ogn-flavoured-aprs#toc2)
pub fn from_aprs_u8(id: u8) -> Option<Type> {
match id {
1 => Some(Self::Glider),
2 => Some(Self::Tow),
3 => Some(Self::Helicopter),
4 => Some(Self::SkyDiver),
5 => Some(Self::DropPlane),
6 => Some(Self::HangGlider),
7 => Some(Self::Paraglider),
8 => Some(Self::MotorAircraft),
9 => Some(Self::Jet),
11 => Some(Self::Balloon),
12 => Some(Self::Blimp),
13 => Some(Self::Unmanned),
15 => Some(Self::Obstacle),
_ => None,
}
}
/// Returns the (english) name of a `Type`
///
/// # Examples
/// ```
/// assert_eq!(Type::Glider.get_name(), "(Motor) Glider");
/// ```
pub fn get_name(&self) -> &'static str {
match self {
Self::Glider => "(Motor) Glider",
Self::Tow => "Tow plane",
Self::Helicopter => "Helicopter / Gyrocopter",
Self::SkyDiver => "Skydiver / Parachute",
Self::DropPlane => "Drop plane",
Self::HangGlider => "Hang glider",
Self::Paraglider => "Paraglider",
Self::MotorAircraft => "Motor aircaft",
Self::Jet => "Jet",
Self::Balloon => "Balloon",
Self::Blimp => "Blimp",
Self::Unmanned => "Unmanned (Drone)",
Self::Obstacle => "Obstacle",
}
}
}
/// Alias for `String`, just for readability.
pub type Id = String;
impl Display for Aircraft {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
write!(
f,
"[ Id: {}, Call sign: {:?}, Registration: {:?}, Type: {:?}, Visible: {} ]",
self.id, self.call_sign, self.registration, self.model, self.visible
)
}
}

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
)
}
}

View File

@@ -0,0 +1,38 @@
use std::collections::HashMap;
use reqwest::IntoUrl;
use super::{conversion::convert, error};
use crate::ogn::{Aircraft, AircraftId};
const LINE_BREAK: char = '\n';
/// Fetches aircraft data from DDB
///
/// # Arguments
///
/// * `url` - The DDB server url
///
/// # Examples
/// ```
/// let url = "https://ddb.example.com/aircraft"
/// let aircraft = fetch_aircraft(url)
/// .await
/// .expect("Could not fetch DDB data");
/// ```
pub async fn fetch_aircraft<A: IntoUrl>(
url: A,
) -> Result<HashMap<AircraftId, Aircraft>, error::Http> {
let response = reqwest::get(url)
.await
.map_err(|_| error::Http::FetchError)?
.text()
.await
.map_err(|_| error::Http::ResponseError)?;
Ok(response
.split(LINE_BREAK)
.filter_map(convert)
.map(|a| (a.id.clone(), a))
.collect::<HashMap<AircraftId, Aircraft>>())
}

View File

@@ -0,0 +1,121 @@
use crate::ogn::Aircraft;
const VALUE_YES: &str = "Y";
const FIELD_SEPARATOR: char = ',';
const IDENTIFIER_COMMENT: char = '#';
const FIELD_ENCLOSURE: char = '\'';
const EMPTY: &str = "";
const TYPE_UNKNOWN: &str = "Unknown";
const INDEX_ID: usize = 1;
const INDEX_TYPE: usize = 2;
const INDEX_REGISTRATION: usize = 3;
const INDEX_CALL_SIGN: usize = 4;
const INDEX_TRACKED: usize = 5;
const INDEX_IDENTIFIED: usize = 6;
/// Tries converting a line of DDB into an `Aircraft` representation
///
/// # Arguments
///
/// * `line` - The line that should be converted
///
/// # Examples
///
/// ```
/// let aircraft = convert("'O','AB1234','ASK-21','D-6507','G1','Y','Y'").unwrap();
/// assert_eq!(aircraft.registration, "D-6507");
/// ```
pub fn convert(line: &str) -> Option<Aircraft> {
if line.starts_with(IDENTIFIER_COMMENT) {
return None;
}
let line = line.replace(FIELD_ENCLOSURE, EMPTY);
let fields = line
.split(FIELD_SEPARATOR)
.map(str::trim)
.collect::<Vec<&str>>();
if fields.len() < 7 {
return None;
}
let model = if fields[INDEX_TYPE] == TYPE_UNKNOWN {
None
} else {
get_as_option(fields[INDEX_TYPE])
};
Some(Aircraft {
id: get_as_option(fields[INDEX_ID])?,
call_sign: get_as_option(fields[INDEX_CALL_SIGN]),
registration: get_as_option(fields[INDEX_REGISTRATION]),
model,
visible: fields[INDEX_IDENTIFIED] == VALUE_YES && fields[INDEX_TRACKED] == VALUE_YES,
})
}
/// Returns `Some(String)`, if `value` is not empty.
/// Otherwise returns `None`
///
/// # Arguments
///
/// * `value` - Value that may be wrapped
///
/// # Examples
/// ```
/// assert!(get_as_option("").is_none());
/// assert!(get_as_option("Value").is_some_and(|v|v == "Value"));
/// ```
fn get_as_option(value: &str) -> Option<String> {
if value.is_empty() {
None
} else {
Some(String::from(value))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_correctly() {
let line = "'O','AB1234','ASK-21','D-6507','G1','Y','Y'";
let result = convert(line);
assert!(result.is_some());
let aircraft = result.unwrap();
assert_eq!(aircraft.id, "AB1234");
assert!(aircraft.call_sign.is_some_and(|v| v == "G1"));
assert!(aircraft.registration.is_some_and(|v| v == "D-6507"));
assert!(aircraft.model.is_some_and(|v| v == "ASK-21"));
assert!(aircraft.visible);
}
#[test]
fn handles_empty_values_correctly() {
let line = "'O','AB1234','Unknown','','G1','Y','Y'";
let result = convert(line);
assert!(result.is_some());
let aircraft = result.unwrap();
assert_eq!(aircraft.id, "AB1234");
assert!(aircraft.call_sign.is_some_and(|v| v == "G1"));
assert!(aircraft.registration.is_none());
assert!(aircraft.model.is_none());
assert!(aircraft.visible);
}
#[test]
fn sets_visible_correctly() {
assert!(convert("'O','AB1234','','','','Y','Y'").is_some_and(|a| a.visible));
assert!(convert("'O','AB1234','','','','Y','N'").is_some_and(|a| !a.visible));
assert!(convert("'O','AB1234','','','','N','Y'").is_some_and(|a| !a.visible));
assert!(convert("'O','AB1234','','','','N','N'").is_some_and(|a| !a.visible));
}
}

View File

@@ -0,0 +1,19 @@
use std::fmt::{Display, Formatter, Result};
/// Enum of `Error`s for failing HTTP requests
#[derive(Debug)]
pub enum Http {
/// Could not fetch data
FetchError,
/// The response is not valid
ResponseError,
}
impl Display for Http {
fn fmt(&self, f: &mut Formatter) -> Result {
match self {
Self::FetchError => write!(f, "Could not fetch data"),
Self::ResponseError => write!(f, "Invalid response"),
}
}
}

21
backend/src/ogn/mod.rs Normal file
View File

@@ -0,0 +1,21 @@
mod aircraft;
pub mod aprs {
mod client;
mod conversion;
mod status;
pub use client::{init, Config};
pub use status::Status;
}
pub mod ddb {
mod client;
mod conversion;
mod error;
pub use client::fetch_aircraft;
}
pub use aircraft::Aircraft;
pub use aircraft::Id as AircraftId;
pub use aircraft::Type as AircraftType;

90
backend/src/position.rs Normal file
View File

@@ -0,0 +1,90 @@
use std::fmt::{Display, Formatter, Result};
use serde::Serialize;
const EARTH_MEAN_RADIUS_KM: f32 = 6371.0;
/// Representation of a position
#[derive(Clone, Serialize)]
pub struct Position {
/// Latitude
pub latitude: f32,
/// Longitude
pub longitude: f32,
}
/// Calculates the distance of two given positions in km.
///
/// # Arguments
///
/// * `pos1` - The first position
/// * `pos2` - The second position
///
/// # Examples
/// ```
/// use aprs::Position
///
/// let pos1 = Position {
/// latitude: 48.858222,
/// longitude: 2.2945,
/// };
///
/// let pos2 = Position {
/// latitude: 48.86055,
/// longitude: 2.3376,
/// };
///
/// assert_eq!(calculate_distance(&pos1, &pos2), 3.1636212);
/// assert_eq!(calculate_distance(&pos2, &pos1), 3.1636212);
/// ```
///
/// # Resources
///
/// * [www.geeksforgeeks.org](https://www.geeksforgeeks.org/haversine-formula-to-find-distance-between-two-points-on-a-sphere/)
/// * [www.movable-type.co.uk](https://www.movable-type.co.uk/scripts/latlong.html)
pub fn calculate_distance(pos1: &Position, pos2: &Position) -> f32 {
let delta_latitude = (pos1.latitude - pos2.latitude).to_radians();
let delta_longitude = (pos1.longitude - pos2.longitude).to_radians();
let latitude_1 = pos1.latitude.to_radians();
let latitude_2 = pos2.latitude.to_radians();
let a = (delta_latitude / 2.0).sin().powi(2)
+ (delta_longitude / 2.0).sin().powi(2) * latitude_1.cos() * latitude_2.cos();
let c = 2.0 * a.sqrt().asin();
c * EARTH_MEAN_RADIUS_KM
}
impl Display for Position {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
write!(
f,
"[ Latitude: {}, Longitude: {} ]",
self.latitude, self.longitude
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn calculates_correct_distance() {
let pos1 = Position {
latitude: 48.858222,
longitude: 2.2945,
};
let pos2 = Position {
latitude: 48.86055,
longitude: 2.3376,
};
/* This value matches online calculators, so I assume it's correct */
assert_eq!(calculate_distance(&pos1, &pos2), 3.1636212);
assert_eq!(calculate_distance(&pos2, &pos1), 3.1636212);
}
}

27
backend/src/time.rs Normal file
View File

@@ -0,0 +1,27 @@
use std::time::{SystemTime, UNIX_EPOCH};
/// Returns current unix timestamp
///
/// # Examples
///
/// ```
/// assert!(get_current_timestamp() > 0);
/// ```
pub fn get_current_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Could not get unix timestamp")
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// Possibly dump test
/// Ensures that function does not fail and returns some value
fn timestamp_not_empty() {
assert!(get_current_timestamp() > 0);
}
}