Initial commit
This commit is contained in:
45
backend/.vscode/launch.json
vendored
Normal file
45
backend/.vscode/launch.json
vendored
Normal 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
2236
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
backend/Cargo.toml
Normal file
24
backend/Cargo.toml
Normal 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
17
backend/Dockerfile
Normal 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
10
backend/src/api/mod.rs
Normal 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;
|
||||
84
backend/src/api/routes/aircraft.rs
Normal file
84
backend/src/api/routes/aircraft.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
10
backend/src/api/routes/overview.rs
Normal file
10
backend/src/api/routes/overview.rs
Normal 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
62
backend/src/api/server.rs
Normal 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
347
backend/src/api/state.rs
Normal 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
37
backend/src/config.rs
Normal 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
97
backend/src/main.rs
Normal 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
146
backend/src/ogn/aircraft.rs
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
38
backend/src/ogn/ddb/client.rs
Normal file
38
backend/src/ogn/ddb/client.rs
Normal 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>>())
|
||||
}
|
||||
121
backend/src/ogn/ddb/conversion.rs
Normal file
121
backend/src/ogn/ddb/conversion.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
19
backend/src/ogn/ddb/error.rs
Normal file
19
backend/src/ogn/ddb/error.rs
Normal 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
21
backend/src/ogn/mod.rs
Normal 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
90
backend/src/position.rs
Normal 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
27
backend/src/time.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user