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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.env
config.json
/backend/target
/frontend/node_modules
/frontend/dist
/docker/privacy-policy.html
.idea/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Laika Schmidt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

50
README.md Normal file
View File

@@ -0,0 +1,50 @@
# above_me
## Goal
This projects listens to the APRS-servers of the [Open Glider Network](http://wiki.glidernet.org/) and keeps track of all incoming aircraft status. It provides an API that returns the latest status for each aircraft within a given location and range. It also provides a simple website that fetches and lists those aircraft status based on your device location automatically.
## Set up
### Native
#### Build
##### Backend
- `cd backend/`
- `cargo build --release`
##### Frontend
- `cd frontend/`
- `npm run copy-dependencies`
- `npm run compile`
#### Run
- Either run `RUST_LOG=error cargo run` inside the [backend/](backend) directory or build the backend and run `RUST_LOG=error ./backend/target/release/above_me`.
- Build the frontend and serve the _frontend/dist_ directory.
(For **development**, you may run `npm run dev` inside the [frontend/](frontend) directory. Requires _Python 3_.)
#### Configuration
Only the backend must be configured. Frontend will run as-is. There are three ways for configuration:
1. _/config.json_ (copy [config.example.json](config.example.json))
2. _/backend/config.json_ (copy [config.example.json](config.example.json), overrides _1._)
3. by environment variables with the prefix _ABOVE\_ME\_\__ (see [/docker/.env.example](docker/.env.example), overrides _1._ and _2._)
### Docker
Configure by setting up _/docker/.env_ (copy [/docker/.env.example](docker/.env.example)) and run `docker compose up`.
(Please note that the _bind\_url_ should be left unconfigured as it is used in the [/docker/docker-compose.yml](docker/docker-compose.yml) config file. Otherwise the proxy-pass may break.)
### Privacy policy
The website contains links to _privacy-policy.html_. You can (and should) set up this privacy policy page. Empty dummy files already exist in the [_docker/_](docker) and [_frontend/src/_](frontend/src) directories.
## API
API-Documentation: [openapi.yml](openapi.yml)
## Status
This project is up and running. I'd say it's "feature-complete" for my use-case.
I'll maybe start building a nicer frontend sometimes. (see [TODO.md](TODO.md))
## License
This code is licensed under the MIT-License (see [LICENSE](LICENSE)). Before using it, make sure to not violate against OGN rules:
see [OGN data usage](https://www.glidernet.org/ogn-data-usage/)
see [ODbL summary](https://opendatacommons.org/licenses/odbl/summary/)
(This project complies to those rules by only publishing data that's [at most 5 minutes old](backend/src/api/state.rs#L127) and [only for aircraft that don't have stealth- or no-tracking-mode active](backend/src/ogn/aprs/conversion.rs#L26).)

19
SECURITY.md Normal file
View File

@@ -0,0 +1,19 @@
# Security Policy
## Supported Versions
Support is limited to the latest version and also the current state in the main branch.
| Version | Supported |
| ------------- | ------------------ |
| _main_ | :white_check_mark: |
| 0.1.4 | :white_check_mark: |
| 0.1.3 | :x: |
| <= 0.1.2 | :x: |
## Reporting a Vulnerability
If you want to report a vulnerability, check if it's possible to create an issue without endangering other users.
If that's not the case, send a mail to the address on my ([its-laika](https://github.com/its-laika)) profile.
I check my mails pretty frequently so expect a reply within a few days. From there on, I'll do my best to fix the vulnerability asap.
If you want to, you can also provide patches directly or via pull requests. A new version is pushed as soon as the vulnerability is fixed.

19
TODO.md Normal file
View File

@@ -0,0 +1,19 @@
# TODOs
## 🦀 Backend
- [x] For some reason, the backend service suddenly stops after a week or so. Find out why and fix this bug.
- [x] Add aircraft type parsing ([doc](http://wiki.glidernet.org/wiki:ogn-flavoured-aprs#toc2))
- [x] Check whether APRS client needs to send regular keep alives
- [x] Documentation
- [x] Code
- [x] API
## 🅰️ Frontend
- [ ] Build a better, fancy frontend
- [ ] Cleaner build process
- [x] Build a frontend
- [x] Add privacy site
## ☁️ Deploy
- [x] Docker support
- [x] Create a _docker-compose.yml_ configuration file

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

11
config.example.json Normal file
View File

@@ -0,0 +1,11 @@
{
"aprs": {
"address": "aprs.example.com",
"user_name": "MYC4LLS1GN",
"password": "************",
"filter": "r/12.3/45.6/78",
"client_id": "my-program 0.1"
},
"ddb_url": "https://example.com/aircraft",
"bind_to": "127.0.0.1:8000"
}

7
docker/.env.example Normal file
View File

@@ -0,0 +1,7 @@
ABOVE_ME__APRS__ADDRESS=aprs.example.com
ABOVE_ME__APRS__USER_NAME=MYC4LLS1GN
ABOVE_ME__APRS__PASSWORD=************
ABOVE_ME__APRS__FILTER=r/12.3/45.6/78
ABOVE_ME__APRS__CLIENT_ID=my-program 0.1
ABOVE_ME__DDB_URL=https://example.com/aircraft
RUST_LOG=warn

23
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
services:
backend:
build:
context: ../backend
environment:
- ABOVE_ME__BIND_TO=0.0.0.0:9000
env_file:
- .env
frontend:
build:
context: ../frontend
restart: unless-stopped
gateway:
image: nginx
depends_on:
- backend
- frontend
restart: unless-stopped
ports:
- "127.0.0.1:9000:8080"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./privacy-policy.html:/usr/share/nginx/html/privacy-policy.html:ro

53
docker/nginx.conf Normal file
View File

@@ -0,0 +1,53 @@
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
server_tokens off;
server {
listen 8080;
server_name _;
add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' data:;";
add_header Permissions-Policy "geolocation=(), midi=(), camera=(), usb=(), payment=(), vr=(), speaker=(), ambient-light-sensor=(), gyroscope=(), microphone=(), usb=(), interest-cohort=()";
add_header Referer "no-referrer";
add_header Referrer-Policy "no-referrer";
add_header Strict-Transport-Security "max-age=63072000" always;
add_header Surrogate-Control "public";
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "DENY";
client_max_body_size 1;
location /r/ {
# Do not log location data
access_log off;
error_log /dev/null emerg;
proxy_pass http://backend:9000/r/;
}
location /status {
proxy_pass http://backend:9000/status;
}
gzip on;
gzip_types *;
expires 1d;
location = /privacy-policy.html {
root /usr/share/nginx/html;
try_files /privacy-policy.html =404;
}
location / {
proxy_pass http://frontend:80/;
}
}
}

View File

@@ -0,0 +1 @@
<!-- Fill this privacy policy. -->

32
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM node:alpine AS install
WORKDIR /app
COPY package-lock.json .
COPY package.json .
RUN npm i
FROM install AS compile
WORKDIR /app
COPY src src
RUN mkdir dist
RUN npx handlebars src/table.handlebars -f dist/table.handlebars.compiled.js
COPY --from=install /app/node_modules/handlebars/dist/handlebars.min.js dist
COPY --from=install /app/node_modules/bootstrap/dist/js/bootstrap.min.js dist
COPY --from=install /app/node_modules/bootstrap/dist/js/bootstrap.min.js.map dist
COPY --from=install /app/node_modules/bootstrap/dist/css/bootstrap.min.css dist
COPY --from=install /app/node_modules/bootstrap/dist/css/bootstrap.min.css.map dist
COPY src/index.html dist
COPY src/privacy-policy.html dist
COPY src/main.js dist
COPY src/style.css dist
FROM nginx:alpine
COPY --from=compile /app/dist /usr/share/nginx/html
CMD ["nginx", "-g", "daemon off;"]

111
frontend/package-lock.json generated Normal file
View File

@@ -0,0 +1,111 @@
{
"name": "above_me",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "above_me",
"version": "0.1.4",
"license": "MIT",
"dependencies": {
"bootstrap": "^5.3.8",
"handlebars": "^4.7.8"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.2",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
"license": "BSD-2-Clause",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"license": "MIT"
}
}
}

25
frontend/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "above_me",
"version": "0.1.4",
"description": "This project contains the frontend for above_me, a service that lets you see what aircraft are currently flying above you.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"copy-dependencies": "rm -rf dist && rsync -av --exclude='*.handlebars' src/ dist/ && cp node_modules/handlebars/dist/handlebars.min.js dist/ && cp node_modules/bootstrap/dist/js/bootstrap.min.js dist && cp node_modules/bootstrap/dist/js/bootstrap.min.js.map dist && cp node_modules/bootstrap/dist/css/bootstrap.min.css dist && cp node_modules/bootstrap/dist/css/bootstrap.min.css.map dist",
"compile": "handlebars src/table.handlebars -f dist/table.handlebars.compiled.js",
"dev": "npm run copy-dependencies && npm run compile && cd dist && python3 -m http.server 8080"
},
"keywords": [
"ogn",
"openglidernet",
"open-glider-net",
"aircraft",
"glider"
],
"author": "Laika Schmidt",
"license": "MIT",
"dependencies": {
"bootstrap": "^5.3.8",
"handlebars": "^4.7.8"
}
}

103
frontend/src/index.html Normal file
View File

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>above_me</title>
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<link rel="stylesheet" href="bootstrap.min.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<nav class="navbar navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="#">
above_me
</a>
<div class="links">
<a href="privacy-policy.html">
Privacy policy
</a>
</div>
</div>
</nav>
<div class="container">
<h3>See what's flying right above you!</h1>
<div class="button-container">
<button type="button" class="btn btn-primary btn-lg" onclick="onClickWhatsAboveMe()"
aria-describedby="whats-above-me-hint">What's above
me?</button>
<div id="whats-above-me-hint" class="form-text">
By clicking this button you accept our <a href="privacy-policy.html">privacy policy</a> and also
that
your current position is sent to the webserver.
</div>
</div>
<div class="alert alert-danger status-message" role="alert" id="no-position-available">
Error. Position for your device could not be loaded. Please check if you gave permission to this site to
access your position.
</div>
<div class="alert alert-danger status-message" role="alert" id="http-error">
Error. Could not fetch data. Open the developer console for more information.
</div>
<div class="alert alert-info status-message" role="alert" id="loading-position">
Loading position...
</div>
<div id="table-container" class="overflow-auto"></div>
<p>
Either click <i>What's above me?</i> to automatically fetch your location
or enter specific coordinates and range and see whats flying above you right now.
</p>
<p>
This site works with data provided by the
<a href="http://wiki.glidernet.org/" target="_blank">Open Glider Network</a>.
(Thank you for your open data & APIs! 😊)
</p>
<form>
<div class="mb-3">
<label for="latitude" class="form-label">Latitude</label>
<input class="form-control" type="number" name="latitude" id="latitude" placeholder="48.858222"
min="-90" max="90" step=".000000001">
</div>
<div class="mb-3">
<label for="longitude" class="form-label">Longitude</label>
<input class="form-control" type="number" name="longitude" id="longitude" placeholder="2.2945"
min="-180" max="180" step=".000000001">
</div>
<div class="mb-3">
<label for="range" class="form-label">Range</label>
<input class="form-control" type="number" name="range" id="range" min="1" max="20" value="20"
placeholder="1 - 20 km" aria-describedby="range-help" step="1">
<div id="range-help" class="form-text">
Range (in <i>km</i>) around given coordinates that should be filtered for.
</div>
</div>
<div class="mb-3 button-container">
<button type="submit" class="btn btn-primary" aria-describedby="submit-hint">Submit</button>
<div id="submit-hint" class="form-text">
By clicking this button you accept our <a href="privacy-policy.html">privacy policy</a> and also
that the given position is sent to the webserver.
</div>
</div>
</form>
</div>
<script src="bootstrap.min.js"></script>
<script src="handlebars.min.js"></script>
<script src="table.handlebars.compiled.js"></script>
<script src="main.js"></script>
</body>
</html>

109
frontend/src/main.js Normal file
View File

@@ -0,0 +1,109 @@
function submit(event) {
document.getElementById('http-error').style.display = 'none';
if (event) {
event.preventDefault();
}
const latitude = document.querySelector('#latitude').value;
const longitude = document.querySelector('#longitude').value;
const range = document.querySelector('#range').value;
if (!latitude || !longitude || !range) {
return;
}
const url = window.location.origin + `/r/${latitude}/${longitude}/${range}`;
const currentTimestamp = Math.round(Date.now() / 1000);
fetch(url)
.then(response => response.json())
.then(response => response.states)
.then(states =>
states.map(s => ({
...s,
speed: s.speed?.toFixed(0),
vertical_speed: s.vertical_speed?.toFixed(1),
altitude: s.altitude?.toFixed(0),
turn_rate: s.turn_rate?.toFixed(1),
course: s.course?.toFixed(0),
position: {
...s.position,
longitude_text: formatCoordinateValue(s.position.longitude, 'E', 'W'),
latitude_text: formatCoordinateValue(s.position.latitude, 'N', 'S'),
},
time_diff: formatTimeDiff(s.time_stamp, currentTimestamp),
distance: s.distance.toFixed(0),
})
))
.then(states =>
Handlebars.templates.table({ states })
)
.then(html => {
document.getElementById('table-container').innerHTML = html;
})
.catch(error => {
document.getElementById('http-error').style.display = 'inherit';
console.error(error);
})
}
function formatCoordinateValue(latitude, directionPositive, directionNegative) {
const degrees = Math.floor(latitude);
const minutes = (latitude - degrees) * 60;
const seconds = (minutes % 1) * 60;
const direction = latitude > 0 ? directionPositive : directionNegative;
return `${degrees.toString().padStart(3, '0')}°${minutes.toFixed(0)}'${seconds.toFixed(0)}" ${direction}`
}
function formatTimeDiff(timestamp1, timestamp2) {
const diff = Math.abs(timestamp1 - timestamp2);
if (diff === 0) {
return 'now';
}
if (diff < 60) {
return `${diff} s`;
}
return `${Math.round(diff / 60)} min`;
}
function onClickWhatsAboveMe() {
document.querySelector('#loading-position').style.display = 'inherit';
navigator.geolocation.getCurrentPosition(
(position) => {
document.querySelector('#latitude').value = Math.round(position.coords.latitude * 1_000_000_000) / 1_000_000_000;
document.querySelector('#longitude').value = Math.round(position.coords.longitude * 1_000_000_000) / 1_000_000_000;
document.querySelector('#no-position-available').style.display = 'none';
document.querySelector('#loading-position').style.display = 'none';
submit();
},
() => {
document.querySelector('#no-position-available').style.display = 'inherit';
document.querySelector('#loading-position').style.display = 'none';
});
}
(function init() {
const urlParams = new URLSearchParams(window.location.search);
for (let param of ['longitude', 'latitude', 'range']) {
if (!urlParams.has(param)) {
continue;
}
let value = parseFloat(urlParams.get(param));
if (!value || isNaN(value)) {
continue;
}
document.querySelector(`#${param}`).value = value;
}
})();
document.querySelector('form').addEventListener('submit', submit);

View File

@@ -0,0 +1 @@
<!-- Fill this privacy policy. -->

73
frontend/src/style.css Normal file
View File

@@ -0,0 +1,73 @@
nav {
margin-bottom: 3%;
}
.button-container {
margin: 20% 0;
display: flex;
justify-content: center;
flex-direction: column;
}
form {
margin-top: 12.5%;
}
form .button-container {
margin-top: 12.5%;
}
@media (min-width: 576px) {
.button-container {
margin: 5% 0;
}
form {
margin-top: 5%;
}
form .button-container {
margin-top: 5%;
}
}
#table-container {
margin: 5% 0;
white-space: nowrap;
}
.status-message {
display: none;
}
h3 {
text-align: center;
}
.form-text {
text-align: center;
}
.links * {
padding-left: 10px;
color: white;
text-decoration: none;
}
.links *:hover {
text-decoration: underline;
}
.variable-value {
text-align: right;
}
.units * {
font-weight: normal;
text-align: right;
font-style: italic;
}
.units * b {
font-weight: bold;
}

View File

@@ -0,0 +1,91 @@
{{#if states}}
<table class="table table-striped table-bordered table-dresponsive">
<thead>
<tr class="table-dark">
<th scope="col">Id</th>
<th scope="col">Distance</th>
<th scope="col">Altitude</th>
<th scope="col">Speed</th>
<th scope="col">Course</th>
<th scope="col">Vertical speed</th>
<th scope="col">Turn rate</th>
<th scope="col">Position</th>
<th scope="col">Last status</th>
</tr>
<tr class="table-primary units">
<th scope="col">
<b>Registration</b> (call sign)<br>
Model / type
</th>
<th scope="col">km</th>
<th scope="col">m</th>
<th scope="col">km/h</th>
<th scope="col">°</th>
<th scope="col">m/s</th>
<th scope="col">turns/min</th>
<th scope="col">
Link to map<br>
(<i><a href="https://live.glidernet.org/">live.glidernet.org</a></i>)
</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{{#each states}}
<tr>
<td class="variable-value">
{{#if aircraft.registration}}
<b>{{aircraft.registration}}</b>
{{#if aircraft.call_sign}}({{aircraft.call_sign}}){{/if}}
<br>{{aircraft.model}}
{{/if}}
{{#unless aircraft.registration}}
<i>unknown</i>
{{#if aircraft.model}}<br>{{/if}}
{{aircraft.model}}
{{/unless}}
</td>
<td class="variable-value">
{{#if distance}}{{distance}}{{/if}}
{{#unless distance}}-{{/unless}}
</td>
<td class="variable-value">
{{#if altitude}}{{altitude}}{{/if}}
{{#unless altitude}}-{{/unless}}
</td>
<td class="variable-value">
{{#if speed}}{{speed}}{{/if}}
{{#unless speed}}-{{/unless}}
</td>
<td class="variable-value">
{{#if course}}{{course}}{{/if}}
{{#unless course}}-{{/unless}}
</td>
<td class="variable-value">
{{#if vertical_speed}}{{vertical_speed}}{{/if}}
{{#unless vertical_speed}}-{{/unless}}
</td>
<td class="variable-value">
{{#if turn_rate}}{{turn_rate}}{{/if}}
{{#unless turn_rate}}-{{/unless}}
</td>
<td class="variable-value">
<a href="https://live.glidernet.org/#c={{position.latitude}},{{position.longitude}}&z=15&s=1"
target="_blank">
{{position.latitude_text}}<br>
{{position.longitude_text}}
</a>
</td>
<td class="variable-value">
{{time_diff}}
</td>
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
{{#unless states}}
<div class="alert alert-secondary" role="alert">
Nothing's above you :(
</div>
{{/unless}}

231
openapi.yml Normal file
View File

@@ -0,0 +1,231 @@
openapi: 3.1.0
info:
title: above_me API
description: |-
The above_me API allows you to fetch information about aircraft for a given position based on the [Open Glider Network](http://wiki.glidernet.org/) data.
contact:
email: laika.schmidt@magenta.de
license:
name: MIT License
url: https://opensource.org/license/mit
version: "0.1.4"
tags:
- name: aircaft
description: Information about aircraft
paths:
/status:
get:
tags:
- status
summary: Gets current API status
description: |-
Returns information about last status update and number of currently
stored states. Can be helpful to check whether the backend still
receives new states.
operationId: getStatus
responses:
"200":
description: Successful operation
content:
application/json:
schema:
$ref: "#/components/schemas/SystemStatus"
/r/{latitude}/{longitude}/{range}:
get:
tags:
- aircaft
summary: Get information about aircraft for a given position
description: |-
Returns information for all aircraft that appeared in the last 5 minutes
in the range of given _latitude_ ± _range_ and _longitude_ ± _range_.
operationId: getAircraftForPosition
parameters:
- name: latitude
in: path
description: Latitude filter
example: 48.858222
required: true
schema:
type: number
format: float
- name: longitude
in: path
description: Longitude filter
example: 2.2945
required: true
schema:
type: number
format: float
- name: range
in: path
description: Range around filter position
example: 15.0
required: true
schema:
type: number
format: float
responses:
"200":
description: Successful operation
content:
application/json:
schema:
$ref: "#/components/schemas/AircraftStatusOverview"
"400":
description: Invalid parameters given
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
components:
schemas:
AircraftStatusOverview:
required:
- latitude
- longitude
- range
- states
type: object
properties:
latitude:
type: number
format: float
examples: [48.858222]
description: Equals given latitude parameter
longitude:
type: number
format: float
examples: [2.2945]
description: Equals given longitude parameter
range:
type: number
format: float
examples: [15.0]
description: Equals given range parameter
states:
type: array
items:
$ref: "#/components/schemas/AircraftStatus"
description: |-
The aircraft states that match the given parameters sorted in ascending oder by distance to given position
AircraftStatus:
type: object
properties:
aircraft:
$ref: "#/components/schemas/Aircraft"
position:
$ref: "#/components/schemas/Position"
speed:
type:
- integer
- "null"
format: int32
examples: [132]
description: Speed of aircraft in _km/h_
minimum: 0
vertical_speed:
type:
- number
- "null"
format: float
examples: [0.32]
description: Vertical speed of aircraft in _m/sec_
altitude:
type:
- integer
- "null"
format: int32
examples: [3431]
description: Altitude of aircraft in _m_
turn_rate:
type:
- number
- "null"
format: float
examples: [3.5]
descripion: Turn rate of aircaft in _turns/min_
course:
type:
- integer
- "null"
format: int32
examples: [152]
description: Course of aircraft
minimum: 0
maximum: 360
time_stamp:
type: integer
format: int64
examples: [670932000]
description: Unix timestamp of latest aircraft status
minimum: 0
distance:
type:
- number
format: float
examples: [13.121989]
description: Distance (in km) of the aircraft to the requested postion
Aircraft:
type: object
properties:
call_sign:
type:
- string
- "null"
examples: ["G1"]
description: Call sign of aircraft
registration:
type:
- string
- "null"
examples: ["D-6507"]
description: Registration of aircraft
model:
type:
- string
- "null"
examples: ["ASK-21"]
description: Airplane model
Position:
type: object
properties:
latitude:
type: number
format: float
examples: [48.858222]
description: Latitude
longitude:
type: number
format: float
examples: [2.2945]
description: Longitude
Error:
type: string
examples:
[
'Invalid URL: Cannot parse value at index 2 with value `"-50000"` to a `u32`',
]
SystemStatus:
type: object
properties:
count:
type: integer
examples: [40]
description: Number of currently stored states
minimum: 0
last_status_update:
type:
- integer
- "null"
format: int64
examples: [670932000]
description: Unix timestamp of latest incoming state
minimum: 0
last_aprs_update:
type:
- integer
- "null"
format: int64
examples: [670932000]
description: Unix timestamp of latest incoming APRS server message
minimum: 0