Initial commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
config.json
|
||||||
|
/backend/target
|
||||||
|
/frontend/node_modules
|
||||||
|
/frontend/dist
|
||||||
|
/docker/privacy-policy.html
|
||||||
|
.idea/
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
50
README.md
Normal 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
19
SECURITY.md
Normal 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
19
TODO.md
Normal 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
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
config.example.json
Normal file
11
config.example.json
Normal 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
7
docker/.env.example
Normal 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
23
docker/docker-compose.yml
Normal 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
53
docker/nginx.conf
Normal 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/;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
docker/privacy-policy.html
Normal file
1
docker/privacy-policy.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<!-- Fill this privacy policy. -->
|
||||||
32
frontend/Dockerfile
Normal file
32
frontend/Dockerfile
Normal 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
111
frontend/package-lock.json
generated
Normal 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
25
frontend/package.json
Normal 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
103
frontend/src/index.html
Normal 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
109
frontend/src/main.js
Normal 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);
|
||||||
1
frontend/src/privacy-policy.html
Normal file
1
frontend/src/privacy-policy.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<!-- Fill this privacy policy. -->
|
||||||
73
frontend/src/style.css
Normal file
73
frontend/src/style.css
Normal 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;
|
||||||
|
}
|
||||||
91
frontend/src/table.handlebars
Normal file
91
frontend/src/table.handlebars
Normal 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
231
openapi.yml
Normal 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
|
||||||
Reference in New Issue
Block a user