├── tests ├── __init__.py ├── model │ ├── __init__.py │ ├── test_sender.py │ └── all_classes.py ├── collect │ ├── __init__.py │ └── test_takeoff_landing.py ├── commands │ ├── __init__.py │ └── test_database.py ├── gateway │ ├── __init__.py │ ├── beacon_data │ │ └── valid_beacon_data │ │ │ ├── inreach.txt │ │ │ ├── skylines.txt │ │ │ ├── pilot_aware.txt │ │ │ ├── naviter.txt │ │ │ ├── fanet.txt │ │ │ ├── spot.txt │ │ │ ├── capturs.txt │ │ │ ├── flarm.txt │ │ │ ├── tracker.txt │ │ │ ├── lt24.txt │ │ │ ├── aprs_aircraft.txt │ │ │ ├── receiver.txt │ │ │ ├── spider.txt │ │ │ ├── flymaster.txt │ │ │ └── aprs_receiver.txt │ └── valid_messages │ │ ├── OGSKYL_Skylines.txt │ │ ├── OGSPOT_Spot.txt │ │ ├── OGNAVI_Naviter.txt │ │ ├── OGCAPT_Capturs.txt │ │ ├── OGNFNT_Fanet.txt │ │ ├── OGFLR_Flarm.txt │ │ ├── OGNTRK.txt │ │ ├── OGLT24_LiveTrack24.txt │ │ ├── APRS_aircraft.txt │ │ ├── OGSPID_Spider.txt │ │ ├── OGNSDR.txt │ │ └── APRS_receiver.txt ├── custom_ddb.txt └── test_utils.py ├── app ├── backend │ ├── __init__.py │ └── ognrange.py ├── collect │ ├── __init__.py │ └── gateway.py ├── gateway │ ├── __init__.py │ ├── process_tools.py │ └── beacon_conversion.py ├── static │ ├── files │ │ ├── url.js.sample │ │ ├── WineButton.png │ │ ├── bootstrap │ │ │ └── LICENSE.md │ │ └── style.css │ ├── img │ │ ├── Blank.gif │ │ └── Transparent.gif │ ├── css │ │ └── flags │ │ │ ├── flags.png │ │ │ └── LICENSE │ └── ognlive │ │ ├── pict │ │ ├── a.gif │ │ ├── c1.gif │ │ ├── c2.gif │ │ ├── c3.gif │ │ ├── c4.gif │ │ ├── c5.gif │ │ ├── h1.gif │ │ ├── h2.gif │ │ ├── h3.gif │ │ ├── l1.gif │ │ ├── m.gif │ │ ├── mm.gif │ │ ├── n.gif │ │ ├── p.gif │ │ ├── pp.gif │ │ ├── z.gif │ │ ├── OGN.png │ │ ├── OGN_b.png │ │ ├── OGN_g.png │ │ ├── OGN_o.png │ │ ├── OGN_p.png │ │ ├── OGN_r.png │ │ ├── bin.gif │ │ ├── close.png │ │ ├── drapd.gif │ │ ├── drape.gif │ │ ├── drapf.gif │ │ ├── drapi.gif │ │ ├── drapn.gif │ │ ├── draps.gif │ │ ├── eye.gif │ │ ├── hel.png │ │ ├── ico.gif │ │ ├── ico.png │ │ ├── min.png │ │ ├── mmm.gif │ │ ├── mod.gif │ │ ├── plu.png │ │ ├── ppp.gif │ │ ├── rec.png │ │ ├── rec0.png │ │ ├── rec1.png │ │ ├── recy.png │ │ ├── tra.gif │ │ ├── yn0.gif │ │ ├── yn1.gif │ │ ├── cordon.gif │ │ ├── dbarrow.gif │ │ ├── favicon.gif │ │ ├── left-3.png │ │ ├── redo-6.png │ │ ├── right-3.png │ │ ├── cancel-5.png │ │ ├── ogn-logo-ani.gif │ │ └── OpenPortGuideLogo_32.png │ │ ├── horizZoomControl.js │ │ ├── util.js │ │ └── ol.css ├── main │ ├── __init__.py │ ├── matplotlib_service.py │ ├── bokeh_utils.py │ └── jinja_filters.py ├── model │ ├── receiver_state.py │ ├── sender_info_origin.py │ ├── geo.py │ ├── aircraft_type.py │ ├── receiver_status_statistic.py │ ├── frequency_scan_file.py │ ├── sender_statistic.py │ ├── receiver_statistic.py │ ├── aggregate_coverage_statistic.py │ ├── country.py │ ├── direction_statistic.py │ ├── takeoff_landing.py │ ├── sender_coverage_statistic.py │ ├── receiver_coverage_statistic.py │ ├── receiver_ranking.py │ ├── sender_position_statistic.py │ ├── receiver_status.py │ ├── __init__.py │ ├── airport.py │ ├── receiver_position.py │ ├── coverage_statistic.py │ ├── sender_info.py │ ├── sender.py │ ├── sender_position.py │ ├── receiver.py │ └── logbook.py ├── tasks │ ├── __init__.py │ └── orm_tasks.py ├── commands │ ├── __init__.py │ ├── flights.py │ ├── logbook.py │ └── gateway.py ├── templates │ ├── airports.html │ ├── senders.html │ ├── sender_ranking.html │ ├── receivers.html │ ├── airport_detail.html │ ├── receiver_detail.html │ ├── receiver_ranking.html │ ├── ogn_live.html │ ├── base.html │ ├── index.html │ ├── logbooks.html │ └── sender_detail.html ├── __init__.py └── utils.py ├── migrations ├── README ├── versions │ ├── 002656878233_initial_revision.py │ ├── dfade4709ef9_pareto_int_float.py │ ├── f69d3839a4e4_added_sender_positions_index_for_name_.py │ ├── 2ab0bbb8b49d_added_constraint_to_senderinfo.py │ ├── a72b2205b55c_added_country_relation_to_senderinfo.py │ ├── 310027ddeea9_added_longtime_rank_to_receiverranking.py │ ├── c53fdb39f5a5_added_frequencyscanfiles.py │ ├── 0dff4f629978_added_receiverstatusstatistic.py │ ├── eb571174e4b2_fix_senderpositionstatistic.py │ ├── 7f5b8f65a977_add_receiverranking.py │ └── f3afd6197391_replaced_rank_with_pareto.py ├── script.py.mako ├── alembic.ini └── env.py ├── setup.cfg ├── MANIFEST.in ├── srtm ├── extract.sh ├── download.sh └── import.sh ├── requirements.txt ├── deployment ├── docker │ ├── cups │ │ └── README.txt │ ├── data │ │ └── README.txt │ ├── Makefile │ ├── ogn-python │ │ ├── wait.sh │ │ ├── prestart.sh │ │ └── Dockerfile │ ├── ogn-pg-importer │ │ ├── Dockerfile │ │ └── docker-entrypoint.sh │ └── docker-compose.yml ├── supervisor │ ├── redmon.conf │ ├── ogn-gateway.conf │ ├── gunicorn.conf │ ├── flower.conf │ ├── celerybeatd.conf │ └── celeryd.conf └── nginx │ └── ogn-python ├── CONTRIBUTORS ├── celery_app.py ├── ogn_python.py ├── tox.ini ├── .github └── dependabot.yml ├── .gitignore ├── Vagrantfile ├── .travis.yml ├── CHANGELOG.md ├── setup.py └── config.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/backend/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/collect/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/gateway/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/model/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/collect/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/gateway/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = F401, F841, E402, E501, E126, E265 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | recursive-include tests * 4 | -------------------------------------------------------------------------------- /app/static/files/url.js.sample: -------------------------------------------------------------------------------- 1 | var url = "http://yourserver.com/OGNRANGE/"; 2 | -------------------------------------------------------------------------------- /srtm/extract.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | unzip -j -d unzipped "downloads/*.zip" 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Install ogn-python 2 | -e . 3 | 4 | # Install development requirements 5 | -e .[dev] 6 | -------------------------------------------------------------------------------- /srtm/download.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | wget -i tiles.txt -P downloads -c --quiet --show-progress 4 | -------------------------------------------------------------------------------- /app/static/img/Blank.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/img/Blank.gif -------------------------------------------------------------------------------- /deployment/docker/cups/README.txt: -------------------------------------------------------------------------------- 1 | All cups file in this directory are imported when the backed service starts. 2 | -------------------------------------------------------------------------------- /srtm/import.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | raster2pgsql -a -e -s 4326 -t 100x100 unzipped/*.hgt elevations | psql 4 | -------------------------------------------------------------------------------- /app/static/css/flags/flags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/css/flags/flags.png -------------------------------------------------------------------------------- /app/static/img/Transparent.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/img/Transparent.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/a.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/a.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/c1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/c1.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/c2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/c2.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/c3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/c3.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/c4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/c4.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/c5.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/c5.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/h1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/h1.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/h2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/h2.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/h3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/h3.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/l1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/l1.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/m.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/m.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/mm.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/mm.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/n.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/n.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/p.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/p.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/pp.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/pp.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/z.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/z.gif -------------------------------------------------------------------------------- /app/static/files/WineButton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/files/WineButton.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/OGN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/OGN.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/OGN_b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/OGN_b.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/OGN_g.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/OGN_g.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/OGN_o.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/OGN_o.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/OGN_p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/OGN_p.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/OGN_r.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/OGN_r.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/bin.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/bin.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/close.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/drapd.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/drapd.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/drape.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/drape.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/drapf.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/drapf.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/drapi.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/drapi.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/drapn.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/drapn.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/draps.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/draps.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/eye.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/eye.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/hel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/hel.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/ico.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/ico.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/ico.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/ico.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/min.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/min.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/mmm.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/mmm.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/mod.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/mod.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/plu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/plu.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/ppp.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/ppp.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/rec.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/rec.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/rec0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/rec0.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/rec1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/rec1.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/recy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/recy.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/tra.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/tra.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/yn0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/yn0.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/yn1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/yn1.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/cordon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/cordon.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/dbarrow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/dbarrow.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/favicon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/favicon.gif -------------------------------------------------------------------------------- /app/static/ognlive/pict/left-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/left-3.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/redo-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/redo-6.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/right-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/right-3.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/cancel-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/cancel-5.png -------------------------------------------------------------------------------- /app/static/ognlive/pict/ogn-logo-ani.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/ogn-logo-ani.gif -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Konstantin Gründger 2 | Fabian P. Schmidt 3 | Dominic Spreitz 4 | -------------------------------------------------------------------------------- /app/static/ognlive/pict/OpenPortGuideLogo_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meisterschueler/ogn-python/HEAD/app/static/ognlive/pict/OpenPortGuideLogo_32.png -------------------------------------------------------------------------------- /app/main/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint("main", __name__) 4 | 5 | import app.main.routes 6 | import app.main.jinja_filters 7 | -------------------------------------------------------------------------------- /celery_app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from app import init_celery 4 | 5 | app = init_celery() 6 | app.conf.imports = app.conf.imports + ("app.tasks",) 7 | -------------------------------------------------------------------------------- /deployment/docker/data/README.txt: -------------------------------------------------------------------------------- 1 | Copy your `public.elevation` file here. 2 | When docker-compose is run the `ogn-pg-importer` service will take care of it. 3 | -------------------------------------------------------------------------------- /deployment/supervisor/redmon.conf: -------------------------------------------------------------------------------- 1 | [program:redmon] 2 | command=/usr/local/bin/redmon --base-path /redmon/ 3 | user=root 4 | directory=/var/www/html 5 | autostart=true 6 | autorestart=true -------------------------------------------------------------------------------- /app/model/receiver_state.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class ReceiverState(enum.Enum): 5 | # lower number == more trustworthy 6 | OK = 0 7 | ZOMBIE = 1 8 | UNKNOWN = 2 9 | OFFLINE = 3 10 | -------------------------------------------------------------------------------- /deployment/docker/Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | @cd ../../; docker build -t ogn -f deployment/docker/ogn-python/Dockerfile . 3 | @cd ../../; docker build -t ogn-pg-importer -f deployment/docker/ogn-pg-importer/Dockerfile . 4 | 5 | -------------------------------------------------------------------------------- /app/model/sender_info_origin.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class SenderInfoOrigin(enum.Enum): 5 | # lower number == more trustworthy 6 | USER_DEFINED = 0 7 | OGN_DDB = 1 8 | FLARMNET = 2 9 | UNKNOWN = 3 10 | -------------------------------------------------------------------------------- /ogn_python.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from app import create_app, db, commands 4 | 5 | app = create_app(os.getenv('FLASK_CONFIG') or 'default') 6 | commands.register(app) 7 | 8 | @app.shell_context_processor 9 | def make_shell_context(): 10 | return dict(app=app, db=db) 11 | -------------------------------------------------------------------------------- /app/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | from .sql_tasks import update_statistics, update_sender_direction_statistics 2 | 3 | from .orm_tasks import transfer_to_database 4 | from .orm_tasks import update_takeoff_landings, update_logbook, update_logbook_max_altitude 5 | from .orm_tasks import import_ddb 6 | -------------------------------------------------------------------------------- /deployment/docker/ogn-python/wait.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # wait a bit for db and backend to properly start before running parms 3 | echo Waiting for backend to start... 4 | bash -c 'while ! /dev/null 5 | $1 $2 $3 $4 $5 $6 $7 $8 $9 -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36,py37,flake8 3 | 4 | [testenv] 5 | setenv = OGN_CONFIG_MODULE = 'config/test.py' 6 | deps = pytest 7 | 8 | commands = 9 | pytest 10 | 11 | [testenv:flake8] 12 | deps = 13 | flake8 14 | 15 | commands = 16 | flake8 app tests 17 | -------------------------------------------------------------------------------- /tests/gateway/beacon_data/valid_beacon_data/inreach.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for Garmin inReach APRS format 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | OGN8A0749>OGINRE,qAS,INREACH:/142700h0448.38N/07600.74W'000/000/A=004583 id300434060496190 inReac True -------------------------------------------------------------------------------- /deployment/docker/ogn-python/prestart.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo Waiting db to start... 3 | bash -c 'while ! /dev/null 4 | flask database init 5 | flask database init_timescaledb 6 | find /cups -name "*.cup" -exec flask database import_airports {} \; 7 | flask database import_ddb 8 | -------------------------------------------------------------------------------- /deployment/supervisor/ogn-gateway.conf: -------------------------------------------------------------------------------- 1 | [program:ogn-feeder] 2 | command=/home/pi/ogn-python/venv/bin/flask gateway run 3 | directory=/home/pi/ogn-python 4 | environment=FLASK_APP=ogn_python.py 5 | 6 | user=pi 7 | stderr_logfile=/var/log/supervisor/ogn-gateway.log 8 | stdout_logfile=/var/log/supervisor/ogn-gateway.log 9 | autostart=true 10 | autorestart=true 11 | -------------------------------------------------------------------------------- /app/static/css/flags/LICENSE: -------------------------------------------------------------------------------- 1 | License 2 | 3 | FamFamFam flag icons are "available for free use for any purpose with no requirement for attribution" 4 | 5 | Blogpotato.de flag icons are licensed under Creative Commons 6 | 7 | 8 | 9 | Author and license terms for Maxmind icon set are unknown. 10 | 11 | - See more at: https://www.flag-sprites.com/en_US/#sthash.cOJO8GvT.dpuf -------------------------------------------------------------------------------- /tests/gateway/valid_messages/OGSKYL_Skylines.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for Skylines (XCsoar) APRS format 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | # The id2816 is the Pilot ID from Skylines (XCsoar) and it is unique within the skyline system 5 | # 6 | FLRDDDD78>OGSKYL,qAS,SKYLINES:/134403h4225.90N/00144.83E'000/000/A=008438 id2816 +000fpm 7 | -------------------------------------------------------------------------------- /deployment/supervisor/gunicorn.conf: -------------------------------------------------------------------------------- 1 | [program:gunicorn] 2 | environment=OGN_CONFIG_MODULE='config/default.py' 3 | command=/home/pi/ogn-python/venv/bin/gunicorn --bind :5000 ogn_python:app 4 | directory=/home/pi/ogn-python 5 | 6 | user=pi 7 | stderr_logfile=/var/log/supervisor/gunicorn.log 8 | stdout_logfile=/var/log/supervisor/gunicorn.log 9 | autostart=true 10 | autorestart=true -------------------------------------------------------------------------------- /tests/gateway/beacon_data/valid_beacon_data/skylines.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for Skylines (XCsoar) APRS format 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | # The id2816 is the Pilot ID from Skylines (XCsoar) and it is unique within the skyline system 5 | # 6 | FLRDDDD78>OGSKYL,qAS,SKYLINES:/134403h4225.90N/00144.83E'000/000/A=008438 id2816 +000fpm 7 | -------------------------------------------------------------------------------- /deployment/supervisor/flower.conf: -------------------------------------------------------------------------------- 1 | [program:flower] 2 | environment=OGN_CONFIG_MODULE='config/default.py' 3 | command=/home/pi/ogn-python/venv/bin/celery flower -A celery_app --port=5555 -l info 4 | directory=/home/pi/ogn-python 5 | 6 | user=pi 7 | stderr_logfile=/var/log/supervisor/celery_flower.log 8 | stdout_logfile=/var/log/supervisor/celery_flower.log 9 | autostart=true 10 | autorestart=true 11 | startsecs=10 -------------------------------------------------------------------------------- /tests/custom_ddb.txt: -------------------------------------------------------------------------------- 1 | #DEVICE_TYPE,DEVICE_ID,AIRCRAFT_MODEL,REGISTRATION,CN,TRACKED,IDENTIFIED,AIRCRAFT_TYPE 2 | 'F','DD4711','HK36 TTC','D-EULE','CU','Y','Y','1' 3 | 'F','DD0815','Ventus 2cxM','D-1234','','Y','N','1' 4 | 'F','DD1234','LS 8 18m','D-8654','12','N','Y','1' 5 | 'F','DD3141','Arcus T','OE-4321','','N','N','1' 6 | 'O','DEADBE','Baloon','OE-ABC','','Y','Y','11' 7 | 'I','999999','A380','G-XXXL','XXL','Y','Y','9' 8 | -------------------------------------------------------------------------------- /tests/gateway/beacon_data/valid_beacon_data/pilot_aware.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for PilotAware's APRS format version OGPAW-1 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | ICA404EC3>OGPAW,qAS,UKWOG:/104337h5211.24N\00032.65W^124/081/A=004026 !W62! id21404EC3 12.5dB +2.2kHz 5 | ICA404EC3>OGPAW,qAS,UKWOG:/104341h5211.18N\00032.53W^131/081/A=004010 !W85! id21404EC3 9.2dB +2.2kHz +10.0dBm 6 | -------------------------------------------------------------------------------- /migrations/versions/002656878233_initial_revision.py: -------------------------------------------------------------------------------- 1 | """Initial revision 2 | 3 | Revision ID: 002656878233 4 | Revises: 5 | Create Date: 2019-04-27 13:47:29.918982 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '002656878233' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | pass 21 | 22 | 23 | def downgrade(): 24 | pass 25 | -------------------------------------------------------------------------------- /app/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from .database import user_cli as database_cli 2 | from .export import user_cli as export_cli 3 | from .flights import user_cli as flights_cli 4 | from .gateway import user_cli as gateway_cli 5 | from .logbook import user_cli as logbook_cli 6 | 7 | 8 | def register(app): 9 | app.cli.add_command(database_cli) 10 | app.cli.add_command(export_cli) 11 | app.cli.add_command(flights_cli) 12 | app.cli.add_command(gateway_cli) 13 | app.cli.add_command(logbook_cli) 14 | -------------------------------------------------------------------------------- /tests/model/test_sender.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import unittest 4 | 5 | from tests.base import TestBaseDB, db 6 | from app.model import Sender, SenderInfo, SenderInfoOrigin 7 | 8 | 9 | class TestStringMethods(TestBaseDB): 10 | def test_expiry_date(self): 11 | device = Sender(name="FLRDD0815", address="DD0815", software_version=6.42) 12 | 13 | self.assertEqual(device.expiry_date(), datetime.date(2019, 10, 31)) 14 | 15 | 16 | if __name__ == "__main__": 17 | unittest.main() 18 | -------------------------------------------------------------------------------- /deployment/nginx/ogn-python: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name api.example.com; 4 | 5 | location / { 6 | proxy_pass "http://localhost:5000"; 7 | proxy_redirect off; 8 | proxy_set_header Host $host; 9 | proxy_set_header X-Real-IP $remote_addr; 10 | fastcgi_read_timeout 300s; 11 | proxy_read_timeout 300; 12 | } 13 | 14 | location /static { 15 | alias /home/pi/ogn-python/app/static/; 16 | } 17 | 18 | error_log /var/log/nginx/api-error.log; 19 | access_log /var/log/nginx/api-access.log; 20 | } 21 | -------------------------------------------------------------------------------- /deployment/supervisor/celerybeatd.conf: -------------------------------------------------------------------------------- 1 | [program:celerybeat] 2 | command=/home/pi/ogn-python/venv/bin/celery -A celery_app beat -l info 3 | directory=/home/pi/ogn-python 4 | environment=FLASK_APP=ogn_python.py 5 | 6 | user=pi 7 | numprocs=1 8 | stdout_logfile=/var/log/supervisor/celery_beat.log 9 | stderr_logfile=/var/log/supervisor/celery_beat.log 10 | autostart=true 11 | autorestart=true 12 | startsecs=10 13 | 14 | ; Causes supervisor to send the termination signal (SIGTERM) to the whole process group. 15 | stopasgroup=true 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: matplotlib 11 | versions: 12 | - 3.4.0 13 | - dependency-name: pandas 14 | versions: 15 | - 1.2.1 16 | - 1.2.2 17 | - 1.2.3 18 | - dependency-name: tqdm 19 | versions: 20 | - 4.56.1 21 | - 4.58.0 22 | - dependency-name: bokeh 23 | versions: 24 | - 2.3.0 25 | -------------------------------------------------------------------------------- /app/model/geo.py: -------------------------------------------------------------------------------- 1 | class Location: 2 | """Represents a location in WGS84""" 3 | 4 | def __init__(self, lon=0, lat=0): 5 | self.longitude = lon 6 | self.latitude = lat 7 | 8 | def to_wkt(self): 9 | return "SRID=4326;POINT({0} {1})".format(self.longitude, self.latitude) 10 | 11 | def __str__(self): 12 | return "{0: 7.4f}, {1:8.4f}".format(self.latitude, self.longitude) 13 | 14 | def as_dict(self): 15 | return {"latitude": round(self.latitude, 8), "longitude": round(self.longitude, 8)} 16 | -------------------------------------------------------------------------------- /deployment/docker/ogn-pg-importer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bullseye-slim AS builder 2 | 3 | RUN apt update && \ 4 | apt install -y --no-install-recommends \ 5 | wget unzip postgis 6 | 7 | RUN wget --no-check-certificate -O borders.zip http://thematicmapping.org/downloads/TM_WORLD_BORDERS-0.3.zip && \ 8 | unzip borders.zip -d /extra 9 | 10 | WORKDIR /extra 11 | 12 | RUN shp2pgsql -s 4326 TM_WORLD_BORDERS-0.3.shp world_borders_temp > world_borders_temp 13 | COPY deployment/docker/ogn-pg-importer/docker-entrypoint.sh . 14 | ENTRYPOINT [ "./docker-entrypoint.sh" ] 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OGN stuff 2 | *.db 3 | *.log 4 | srtm/ 5 | 6 | # Python 7 | __pycache__/ 8 | *.py[cod] 9 | 10 | # Distribution / packaging 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .cache 28 | nosetests.xml 29 | coverage.xml 30 | 31 | # Sphinx documentation 32 | docs/_build/ 33 | 34 | # Editors 35 | *.swp 36 | *.swo 37 | 38 | # Python virtualenv 39 | env/ 40 | 41 | # Celery beat 42 | celerybeat-schedule 43 | -------------------------------------------------------------------------------- /app/model/aircraft_type.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class AircraftType(enum.Enum): 5 | UNKNOWN = 0 6 | GLIDER_OR_MOTOR_GLIDER = 1 7 | TOW_TUG_PLANE = 2 8 | HELICOPTER_ROTORCRAFT = 3 9 | PARACHUTE = 4 10 | DROP_PLANE = 5 11 | HANG_GLIDER = 6 12 | PARA_GLIDER = 7 13 | POWERED_AIRCRAFT = 8 14 | JET_AIRCRAFT = 9 15 | FLYING_SAUCER = 10 16 | BALLOON = 11 17 | AIRSHIP = 12 18 | UNMANNED_AERIAL_VEHICLE = 13 19 | STATIC_OBJECT = 15 20 | 21 | @staticmethod 22 | def list(): 23 | return list(map(lambda c: c.value, AircraftType)) 24 | -------------------------------------------------------------------------------- /tests/gateway/valid_messages/OGSPOT_Spot.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for SPOT APRS format 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | # id0-28... is a unique ID within the SPOT system. 5 | # SPOT2 is the Spot model 6 | # GOOD is the battery status or some other help messages. 7 | # 8 | ICA3E7540>OGSPOT,qAS,SPOT:/161427h1448.35S/04610.86W'000/000/A=008677 id0-2860357 SPOT3 GOOD 9 | ICA3E7540>OGSPOT,qAS,SPOT:/162923h1431.99S/04604.33W'000/000/A=006797 id0-2860357 SPOT3 GOOD 10 | ICA3E7540>OGSPOT,qAS,SPOT:/163421h1430.38S/04604.43W'000/000/A=007693 id0-2860357 SPOT3 GOOD 11 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /tests/gateway/valid_messages/OGNAVI_Naviter.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for NAVITER's APRS format version OGNAVI-1 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | NAV042121>OGNAVI,qAS,NAVITER:/140648h4550.36N/01314.85E'090/152/A=001086 !W47! id0440042121 +000fpm +0.5rot 5 | NAV04220E>OGNAVI,qAS,NAVITER:/140748h4552.27N/01155.61E'090/012/A=006562 !W81! id044004220E +060fpm +1.2rot 6 | NAV07220E>OGNAVI,qAS,NAVITER:/125447h4557.77N/01220.19E'258/056/A=006562 !W76! id1C4007220E +180fpm +0.0rot 7 | FLRFFFFFF>OGNAVI,NAV07220E*,qAS,NAVITER:/092002h1000.00S/01000.00W'000/000/A=003281 !W00! id2820FFFFFF +300fpm +1.7rot -------------------------------------------------------------------------------- /tests/gateway/beacon_data/valid_beacon_data/naviter.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for NAVITER's APRS format version OGNAVI-1 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | NAV042121>OGNAVI,qAS,NAVITER:/140648h4550.36N/01314.85E'090/152/A=001086 !W47! id0440042121 +000fpm +0.5rot 5 | NAV04220E>OGNAVI,qAS,NAVITER:/140748h4552.27N/01155.61E'090/012/A=006562 !W81! id044004220E +060fpm +1.2rot 6 | NAV07220E>OGNAVI,qAS,NAVITER:/125447h4557.77N/01220.19E'258/056/A=006562 !W76! id1C4007220E +180fpm +0.0rot 7 | FLRFFFFFF>OGNAVI,NAV07220E*,qAS,NAVITER:/092002h1000.00S/01000.00W'000/000/A=003281 !W00! id2820FFFFFF +300fpm +1.7rot 8 | -------------------------------------------------------------------------------- /deployment/supervisor/celeryd.conf: -------------------------------------------------------------------------------- 1 | [program:celery] 2 | command=/home/pi/ogn-python/venv/bin/celery -A celery_app worker -l info 3 | directory=/home/pi/ogn-python 4 | environment=FLASK_APP=ogn_python.py 5 | 6 | user=pi 7 | numprocs=1 8 | stdout_logfile=/var/log/supervisor/celery_worker.log 9 | stderr_logfile=/var/log/supervisor/celery_worker.log 10 | autostart=true 11 | autorestart=true 12 | startsecs=10 13 | 14 | ; Need to wait for currently executing tasks to finish at shutdown. 15 | ; Increase this if you have very long running tasks. 16 | stopwaitsecs = 600 17 | 18 | ; Causes supervisor to send the termination signal (SIGTERM) to the whole process group. 19 | stopasgroup=true 20 | -------------------------------------------------------------------------------- /tests/gateway/beacon_data/valid_beacon_data/fanet.txt: -------------------------------------------------------------------------------- 1 | # With OGN software 0.2.7 receivers have the dstcall "OGNFNT" 2 | # 3 | FNT1103CE>OGNFNT,qAS,FNB1103CE:/183727h5057.94N/00801.00Eg355/002/A=001042 !W10! id1E1103CE +03fpm 4 | FNT1103CE>OGNFNT,qAS,FNB1103CE:/183729h5057.94N/00801.00Eg354/001/A=001042 !W10! id1E1103CE +07fpm 5 | FNT1103CE>OGNFNT,qAS,FNB1103CE:/183731h5057.94N/00801.00Eg354/001/A=001042 !W10! id1E1103CE +05fpm 6 | FNT1103CE>OGNFNT,qAS,FNB1103CE:/183734h5057.94N/00801.00Eg354/001/A=001042 !W30! id1E1103CE -10fpm 7 | FNT1103CE>OGNFNT,qAS,FNB1103CE:/183736h5057.94N/00801.00Eg354/001/A=001042 !W40! id1E1103CE -02fpm 8 | FNB1103CE>OGNFNT,TCPIP*,qAC,GLIDERN3:/183738h5057.95NI00801.00E&/A=001042 9 | -------------------------------------------------------------------------------- /tests/gateway/beacon_data/valid_beacon_data/spot.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for SPOT APRS format 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | # id0-28... is a unique ID within the SPOT system. 5 | # SPOT2 is the Spot model 6 | # GOOD is the battery status or some other help messages. 7 | # 8 | ICA3E7540>OGSPOT,qAS,SPOT:/161427h1448.35S/04610.86W'000/000/A=008677 id0-2860357 SPOT3 GOOD 9 | ICA3E7540>OGSPOT,qAS,SPOT:/162923h1431.99S/04604.33W'000/000/A=006797 id0-2860357 SPOT3 GOOD 10 | ICA3E7540>OGSPOT,qAS,SPOT:/163421h1430.38S/04604.43W'000/000/A=007693 id0-2860357 SPOT3 GOOD 11 | FLRDF0CBA>OGSPOT,qAS,SPOT:/145808h3317.84S/07021.04W'000/000/A=010085 id0-2120121 SPOTCONNECT GOOD -------------------------------------------------------------------------------- /tests/gateway/beacon_data/valid_beacon_data/capturs.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for the Capture APRS format 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/062744h4845.03N/00230.46E'000/000/ 5 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/064243h4839.64N/00236.78E'000/085/A=000410 6 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/064548h4838.87N/00234.03E'000/042/A=000377 7 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/064847h4837.95N/00234.36E'000/000/ 8 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/065144h4837.56N/00233.80E'000/000/ 9 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/065511h4837.63N/00233.79E'000/000/ 10 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/070016h4837.63N/00233.77E'000/001/A=000360 11 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/070153h4837.62N/00233.77E'000/001/A=000344 -------------------------------------------------------------------------------- /tests/gateway/valid_messages/OGCAPT_Capturs.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for the Capture APRS format 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/062744h4845.03N/00230.46E'000/000/ 5 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/064243h4839.64N/00236.78E'000/085/A=000410 6 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/064548h4838.87N/00234.03E'000/042/A=000377 7 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/064847h4837.95N/00234.36E'000/000/ 8 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/065144h4837.56N/00233.80E'000/000/ 9 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/065511h4837.63N/00233.79E'000/000/ 10 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/070016h4837.63N/00233.77E'000/001/A=000360 11 | FLRDDEEF1>OGCAPT,qAS,CAPTURS:/070153h4837.62N/00233.77E'000/001/A=000344 12 | 13 | -------------------------------------------------------------------------------- /tests/gateway/valid_messages/OGNFNT_Fanet.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for FANET (Skytraxx) APRS format 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | FNT1103CE>OGNFNT,qAS,FNB1103CE:/183727h5057.94N/00801.00Eg355/002/A=001042 !W10! id1E1103CE +03fpm 5 | FNT1103CE>OGNFNT,qAS,FNB1103CE:/183729h5057.94N/00801.00Eg354/001/A=001042 !W10! id1E1103CE +07fpm 6 | FNT1103CE>OGNFNT,qAS,FNB1103CE:/183731h5057.94N/00801.00Eg354/001/A=001042 !W10! id1E1103CE +05fpm 7 | FNT1103CE>OGNFNT,qAS,FNB1103CE:/183734h5057.94N/00801.00Eg354/001/A=001042 !W30! id1E1103CE -10fpm 8 | FNT1103CE>OGNFNT,qAS,FNB1103CE:/183736h5057.94N/00801.00Eg354/001/A=001042 !W40! id1E1103CE -02fpm 9 | FNB1103CE>OGNFNT,TCPIP*,qAC,GLIDERN3:/183738h5057.95NI00801.00E&/A=001042 10 | -------------------------------------------------------------------------------- /tests/gateway/beacon_data/valid_beacon_data/flarm.txt: -------------------------------------------------------------------------------- 1 | # With OGN software 0.2.7 flarms have the dstcall "OGFLR" 2 | # 3 | FLRDD89C9>OGFLR,qAS,LIDH:/115054h4543.22N/01132.84E'260/072/A=002542 !W10! id06DD89C9 +198fpm -0.8rot 7.0dB 0e +0.7kHz gps2x3 4 | FLRDD98C6>OGFLR,qAS,LIDH:/115054h4543.21N/01132.80E'255/074/A=002535 !W83! id0ADD98C6 +158fpm -1.8rot 10.5dB 0e -0.8kHz gps2x3 s6.09 h02 5 | ICAA8CBA8>OGFLR,qAS,MontCAIO:/231150z4512.12N\01059.03E^192/106/A=009519 !W20! id21A8CBA8 -039fpm +0.0rot 3.5dB 2e -8.7kHz gps1x2 s6.09 h43 rDF0267 6 | ICAA8CBA8>OGFLR,qAS,MontCAIO:/114949h4512.44N\01059.12E^190/106/A=009522 !W33! id21A8CBA8 -039fpm +0.1rot 4.5dB 1e -8.7kHz gps1x2 +14.3dBm 7 | ICA3D1C35>OGFLR,qAS,Padova:/094220h4552.41N/01202.28E'110/099/A=003982 !W96! id053D1C35 -1187fpm +0.0rot 0.8dB 2e +4.5kHz gps1x2 s6.09 h32 rDD09D0 -------------------------------------------------------------------------------- /tests/commands/test_database.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | 4 | from flask import current_app 5 | from app.model import SenderInfo 6 | from app.commands.database import import_ddb 7 | 8 | from tests.base import TestBaseDB, db 9 | 10 | 11 | class TestDatabase(TestBaseDB): 12 | @unittest.skip('TODO: FIXME') 13 | def test_import_ddb(self): 14 | runner = current_app.test_cli_runner() 15 | ddb_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../custom_ddb.txt")) 16 | result = runner.invoke(import_ddb, ['--path', ddb_path]) 17 | self.assertEqual(result.exit_code, 0) 18 | 19 | sender_infos = db.session.query(SenderInfo).all() 20 | self.assertEqual(len(sender_infos), 6) 21 | 22 | 23 | if __name__ == "__main__": 24 | unittest.main() 25 | -------------------------------------------------------------------------------- /deployment/docker/ogn-pg-importer/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # wait a bit for db and backend to properly start 3 | echo Waiting backend to start... 4 | bash -c 'while ! /dev/null 5 | psql -t -d ogn -c "SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_name = 'elevation' )" | grep -q t 6 | if [ $? -eq 1 ] 7 | then 8 | echo Importing elevation... 9 | cat /data/public.elevation | psql -d ogn > /dev/null 10 | echo Importing borders... 11 | cat /extra/world_borders_temp | psql -d ogn > /dev/null 12 | psql -d ogn -c "INSERT INTO countries SELECT * FROM world_borders_temp;" 13 | psql -d ogn -c "DROP TABLE world_borders_temp;" 14 | fi 15 | echo Elevation and borders added 16 | while true; do sleep 10000; done; 17 | -------------------------------------------------------------------------------- /tests/model/all_classes.py: -------------------------------------------------------------------------------- 1 | import os 2 | from enum import EnumMeta 3 | 4 | import unittest 5 | import inspect 6 | 7 | os.environ["OGN_CONFIG_MODULE"] = "config/test.py" 8 | 9 | import app.model # noqa: E402 10 | 11 | 12 | class TestStringMethods(unittest.TestCase): 13 | def test_string(self): 14 | failures = 0 15 | for name, obj in inspect.getmembers(app.model): 16 | try: 17 | if inspect.isclass(obj) and not isinstance(obj, EnumMeta): 18 | print(obj()) 19 | except AttributeError as e: 20 | print("Failed: {}".format(name)) 21 | failures += 1 22 | 23 | if failures > 0: 24 | raise AssertionError("Not all classes are good") 25 | 26 | 27 | if __name__ == "__main__": 28 | unittest.main() 29 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | $script = < 42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /app/collect/gateway.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from flask import current_app 3 | 4 | from app import redis_client 5 | from app.gateway.message_handling import sender_position_csv_strings_to_db, receiver_position_csv_strings_to_db, receiver_status_csv_strings_to_db 6 | 7 | 8 | def transfer_from_redis_to_database(): 9 | def unmapping(string): 10 | return string[0].decode('utf-8') 11 | 12 | receiver_status_data = list(map(unmapping, redis_client.zpopmin('receiver_status', 100000))) 13 | receiver_position_data = list(map(unmapping, redis_client.zpopmin('receiver_position', 100000))) 14 | sender_status_data = list(map(unmapping, redis_client.zpopmin('sender_status', 100000))) 15 | sender_position_data = list(map(unmapping, redis_client.zpopmin('sender_position', 100000))) 16 | 17 | receiver_status_csv_strings_to_db(lines=receiver_status_data) 18 | receiver_position_csv_strings_to_db(lines=receiver_position_data) 19 | sender_position_csv_strings_to_db(lines=sender_position_data) 20 | 21 | current_app.logger.debug(f"transfer_from_redis_to_database: rx_stat: {len(receiver_status_data):6d}\trx_pos: {len(receiver_position_data):6d}\ttx_stat: {len(sender_status_data):6d}\ttx_pos: {len(sender_position_data):6d}") 22 | 23 | finish_message = f"Database: {len(receiver_status_data)+len(receiver_position_data)+len(sender_status_data)+len(sender_position_data)} inserted" 24 | return finish_message 25 | -------------------------------------------------------------------------------- /migrations/versions/310027ddeea9_added_longtime_rank_to_receiverranking.py: -------------------------------------------------------------------------------- 1 | """Added longtime_rank to ReceiverRanking 2 | 3 | Revision ID: 310027ddeea9 4 | Revises: eb571174e4b2 5 | Create Date: 2020-12-04 22:11:31.958278 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '310027ddeea9' 14 | down_revision = 'eb571174e4b2' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('receiver_rankings', sa.Column('longtime_local_rank', sa.Integer(), nullable=True)) 22 | op.add_column('receiver_rankings', sa.Column('longtime_local_rank_delta', sa.Integer(), nullable=True)) 23 | op.add_column('receiver_rankings', sa.Column('longtime_global_rank', sa.Integer(), nullable=True)) 24 | op.add_column('receiver_rankings', sa.Column('longtime_global_rank_delta', sa.Integer(), nullable=True)) 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade(): 29 | # ### commands auto generated by Alembic - please adjust! ### 30 | op.drop_column('receiver_rankings', 'longtime_global_rank_delta') 31 | op.drop_column('receiver_rankings', 'longtime_global_rank') 32 | op.drop_column('receiver_rankings', 'longtime_local_rank_delta') 33 | op.drop_column('receiver_rankings', 'longtime_local_rank') 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /app/gateway/process_tools.py: -------------------------------------------------------------------------------- 1 | import os 2 | import gzip 3 | import time 4 | from contextlib import contextmanager 5 | 6 | from flask import current_app 7 | from app import db 8 | 9 | 10 | @contextmanager 11 | def open_file(filename): 12 | """Opens a regular OR gzipped textfile for reading.""" 13 | 14 | file = open(filename, "rb") 15 | a = file.read(2) 16 | file.close() 17 | if a == b"\x1f\x8b": 18 | file = gzip.open(filename, "rt", encoding="latin-1") 19 | else: 20 | file = open(filename, "rt", encoding="latin-1") 21 | 22 | try: 23 | yield file 24 | finally: 25 | file.close() 26 | 27 | 28 | class Timer(object): 29 | def __init__(self, name=None): 30 | self.name = name 31 | 32 | def __enter__(self): 33 | self.tstart = time.time() 34 | 35 | def __exit__(self, type, value, traceback): 36 | if self.name: 37 | print("[{}]".format(self.name)) 38 | print("Elapsed: {}".format(time.time() - self.tstart)) 39 | 40 | 41 | def export_to_path(path): 42 | connection = db.engine.raw_connection() 43 | cursor = connection.cursor() 44 | 45 | aircraft_beacons_file = os.path.join(path, "sender_positions.csv.gz") 46 | with gzip.open(aircraft_beacons_file, "wt", encoding="utf-8") as gzip_file: 47 | cursor.copy_expert("COPY ({}) TO STDOUT WITH (DELIMITER ',', FORMAT CSV, HEADER, ENCODING 'UTF-8');".format("SELECT * FROM sender_positions"), gzip_file) 48 | -------------------------------------------------------------------------------- /tests/gateway/beacon_data/valid_beacon_data/aprs_aircraft.txt: -------------------------------------------------------------------------------- 1 | # Until OGN software 0.2.6 all beacons (flarms, ogn trackers and receivers) have the dstcall "APRS" 2 | # These are example beacons for flarms and ogn trackers 3 | # 4 | FLRDDA5BA>APRS,qAS,LFMX:/165829h4415.41N/00600.03E'342/049/A=005524 id0ADDA5BA -454fpm -1.1rot 8.8dB 0e +51.2kHz gps4x5 5 | ICA4B0E3A>APRS,qAS,Letzi:/165319h4711.75N\00802.59E^327/149/A=006498 id154B0E3A -3959fpm +0.5rot 9.0dB 0e -6.3kHz gps1x3 6 | FLRDDB091>APRS,qAS,Letzi:/165831h4740.04N/00806.01EX152/124/A=004881 id06DD8E80 +198fpm +0.0rot 6.5dB 13e +4.0kHz gps3x4 7 | FLRDDDD33>APRS,qAS,LFNF:/165341h4344.27N/00547.41E'/A=000886 id06DDDD33 +020fpm +0.0rot 20.8dB 0e -14.3kHz gps3x4 8 | FLRDDE026>APRS,qAS,LFNF:/165341h4358.58N/00553.89E'204/055/A=005048 id06DDE026 +257fpm +0.1rot 7.2dB 0e -0.8kHz gps4x7 9 | ICA484A9C>APRS,qAS,LFMX:/165341h4403.50N/00559.67E'/A=001460 id05484A9C +000fpm +0.0rot 18.0dB 0e +3.5kHz gps4x7 10 | OGNE95A16>APRS,qAS,Sylwek:/165641h5001.94N/01956.91E'270/004/A=000000 id07E95A16 +000fpm +0.1rot 37.8dB 0e -0.4kHz 11 | ZK-GSC>APRS,qAS,Omarama:/165202h4429.25S/16959.33E'/A=001407 id05C821EA +020fpm +0.0rot 16.8dB 0e -3.1kHz gps1x3 hear1084 hearB597 hearB598 12 | # 13 | # since 0.2.6 a aircraft beacon needs just an ID, climb rate and turn rate or just the ID 14 | ICA3ECE59>APRS,qAS,GLDRTR:/171254h5144.78N/00616.67E'263/000/A=000075 id093D0930 +000fpm +0.0rot 15 | ICA3ECE59>APRS,qAS,GLDRTR:/171254h5144.78N/00616.67E'263/000/A=000075 id053ECE59 -------------------------------------------------------------------------------- /app/main/matplotlib_service.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | from app.model import DirectionStatistic 3 | import random 4 | import numpy as np 5 | import matplotlib.pyplot as plt 6 | from matplotlib.figure import Figure 7 | 8 | 9 | def create_range_figure2(sender_id): 10 | fig = Figure() 11 | axis = fig.add_subplot(1, 1, 1) 12 | xs = range(100) 13 | ys = [random.randint(1, 50) for x in xs] 14 | axis.plot(xs, ys) 15 | 16 | return fig 17 | 18 | 19 | def create_range_figure(sender_id): 20 | sds = db.session.query(DirectionStatistic) \ 21 | .filter(DirectionStatistic.sender_id == sender_id) \ 22 | .order_by(DirectionStatistic.directions_count.desc()) \ 23 | .limit(1) \ 24 | .one() 25 | 26 | fig = Figure() 27 | 28 | direction_data = sds.direction_data 29 | max_range = max([r['max_range'] / 1000.0 for r in direction_data]) 30 | 31 | theta = np.array([i['direction'] / 180 * np.pi for i in direction_data]) 32 | radii = np.array([i['max_range'] / 1000 if i['max_range'] > 0 else 0 for i in direction_data]) 33 | width = np.array([13 / 180 * np.pi for i in direction_data]) 34 | colors = plt.cm.viridis(radii / max_range) 35 | 36 | ax = fig.add_subplot(111, projection='polar') 37 | ax.bar(theta, radii, width=width, bottom=0.0, color=colors, edgecolor='b', alpha=0.5) 38 | #ax.set_rticks([0, 25, 50, 75, 100, 125, 150]) 39 | ax.set_theta_zero_location("N") 40 | ax.set_theta_direction(-1) 41 | 42 | fig.suptitle(f"Range between sender '{sds.sender.name}' and receiver '{sds.receiver.name}'") 43 | 44 | return fig 45 | -------------------------------------------------------------------------------- /tests/gateway/valid_messages/APRS_aircraft.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for the (deprecated) APRS format for flarms and ogn trackers 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | # Until OGN software 0.2.6 all beacons (flarms, ogn trackers and receivers) have the dstcall "APRS" 5 | # These are example beacons for flarms and ogn trackers 6 | # 7 | FLRDDA5BA>APRS,qAS,LFMX:/165829h4415.41N/00600.03E'342/049/A=005524 id0ADDA5BA -454fpm -1.1rot 8.8dB 0e +51.2kHz gps4x5 8 | ICA4B0E3A>APRS,qAS,Letzi:/165319h4711.75N\00802.59E^327/149/A=006498 id154B0E3A -3959fpm +0.5rot 9.0dB 0e -6.3kHz gps1x3 9 | FLRDDB091>APRS,qAS,Letzi:/165831h4740.04N/00806.01EX152/124/A=004881 id06DD8E80 +198fpm +0.0rot 6.5dB 13e +4.0kHz gps3x4 10 | FLRDDDD33>APRS,qAS,LFNF:/165341h4344.27N/00547.41E'/A=000886 id06DDDD33 +020fpm +0.0rot 20.8dB 0e -14.3kHz gps3x4 11 | FLRDDE026>APRS,qAS,LFNF:/165341h4358.58N/00553.89E'204/055/A=005048 id06DDE026 +257fpm +0.1rot 7.2dB 0e -0.8kHz gps4x7 12 | ICA484A9C>APRS,qAS,LFMX:/165341h4403.50N/00559.67E'/A=001460 id05484A9C +000fpm +0.0rot 18.0dB 0e +3.5kHz gps4x7 13 | OGNE95A16>APRS,qAS,Sylwek:/165641h5001.94N/01956.91E'270/004/A=000000 id07E95A16 +000fpm +0.1rot 37.8dB 0e -0.4kHz 14 | ZK-GSC>APRS,qAS,Omarama:/165202h4429.25S/16959.33E'/A=001407 id05C821EA +020fpm +0.0rot 16.8dB 0e -3.1kHz gps1x3 hear1084 hearB597 hearB598 15 | # 16 | # since 0.2.6 a aircraft beacon needs just an ID, climb rate and turn rate or just the ID 17 | ICA3ECE59>APRS,qAS,GLDRTR:/171254h5144.78N/00616.67E'263/000/A=000075 id093D0930 +000fpm +0.0rot 18 | ICA3ECE59>APRS,qAS,GLDRTR:/171254h5144.78N/00616.67E'263/000/A=000075 id053ECE59 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## Unreleased 4 | 5 | ## 0.3.0 - 2016-10-22 6 | - Changed database for OGN v0.2.5 receiver beacons 7 | - Moved to PostGIS, PostgreSQL is now mandantory 8 | - Changed database schema (added airport, added relations, added `aircraft_type`, removed unused fields) 9 | - Added Airport manager with command line option `db.import_airports`, 10 | default is WELT2000 11 | - Logbook: instead of lat, lon and name of the airport just pass the name 12 | - Logbook: optional utc offset, optional single day selection 13 | - Logbook: remark if different airport is used for takeoff or landing 14 | - Logbook: several accuracy and speed improvements 15 | - DDB: consider `aircraft_type` 16 | - Moved exceptions from `ogn.exceptions` to `ogn.parser.exceptions` 17 | - Moved parsing from `ogn.model.*` to `ogn.parser` 18 | - Moved the APRS- & OGN-Parser, the APRS-client and the DDB-client to [python-ogn-client](https://github.com/glidernet/python-ogn-client) 19 | 20 | ## 0.2.1 - 2016-02-17 21 | First and last release via PyPI. 22 | - Added CHANGELOG. 23 | 24 | ## 0.2 25 | - Changed database schema. 26 | - Changed aprs app name to 'ogn-gateway-python'. 27 | - Moved repository to github-organisation glidernet. 28 | - Added exception handling to the packet parser. 29 | - Added some tests for ogn.gateway.client. 30 | - Added setup.py to build this package. 31 | - Added configuration via python modules. 32 | - Added scheduled tasks with celery. 33 | - Renamed command line option `db.updateddb` to `db.import_ddb`. 34 | - Added command line options `db.drop`, `db.import_file`, `db.upgrade`, 35 | `logbook.compute` and `show.devices.stats`. 36 | 37 | ## 0.1 38 | Initial version. 39 | -------------------------------------------------------------------------------- /app/tasks/orm_tasks.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from app.collect.logbook import update_takeoff_landings as logbook_update_takeoff_landings, update_logbook as logbook_update 4 | from app.collect.logbook import update_max_altitudes as logbook_update_max_altitudes 5 | 6 | from app.collect.database import read_ddb, merge_sender_infos 7 | 8 | from app.collect.gateway import transfer_from_redis_to_database 9 | 10 | from app import db, celery 11 | 12 | 13 | @celery.task(name="transfer_to_database") 14 | def transfer_to_database(): 15 | """Transfer APRS data from Redis to database.""" 16 | 17 | result = transfer_from_redis_to_database() 18 | return result 19 | 20 | 21 | @celery.task(name="update_takeoff_landings") 22 | def update_takeoff_landings(last_minutes): 23 | """Compute takeoffs and landings.""" 24 | 25 | end = datetime.utcnow() 26 | start = end - timedelta(minutes=last_minutes) 27 | result = logbook_update_takeoff_landings(start=start, end=end) 28 | return result 29 | 30 | 31 | @celery.task(name="update_logbook") 32 | def update_logbook(offset_days=None): 33 | """Add/update logbook entries.""" 34 | 35 | result = logbook_update(offset_days=offset_days) 36 | return result 37 | 38 | 39 | @celery.task(name="update_logbook_max_altitude") 40 | def update_logbook_max_altitude(): 41 | """Add max altitudes in logbook when flight is complete (takeoff and landing).""" 42 | 43 | result = logbook_update_max_altitudes() 44 | return result 45 | 46 | 47 | @celery.task(name="import_ddb") 48 | def import_ddb(): 49 | """Import registered devices from the DDB.""" 50 | 51 | sender_info_dicts = read_ddb() 52 | result = merge_sender_infos(sender_info_dicts) 53 | return result 54 | -------------------------------------------------------------------------------- /migrations/versions/c53fdb39f5a5_added_frequencyscanfiles.py: -------------------------------------------------------------------------------- 1 | """Added UploadedFile 2 | 3 | Revision ID: c53fdb39f5a5 4 | Revises: 002656878233 5 | Create Date: 2020-12-01 18:18:43.404091 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'c53fdb39f5a5' 14 | down_revision = '002656878233' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('frequency_scan_files', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('name', sa.String(), nullable=False), 24 | sa.Column('gain', sa.Float(precision=2), nullable=False), 25 | sa.Column('upload_ip_address', sa.String(), nullable=False), 26 | sa.Column('upload_timestamp', sa.DateTime(), nullable=False), 27 | sa.Column('receiver_id', sa.Integer(), nullable=True), 28 | sa.ForeignKeyConstraint(['receiver_id'], ['receivers.id'], ondelete='CASCADE'), 29 | sa.PrimaryKeyConstraint('id') 30 | ) 31 | op.create_index(op.f('ix_frequency_scan_files_receiver_id'), 'frequency_scan_files', ['receiver_id'], unique=False) 32 | op.create_index(op.f('ix_frequency_scan_files_upload_timestamp'), 'frequency_scan_files', ['upload_timestamp'], unique=False) 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade(): 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.drop_index(op.f('ix_frequency_scan_files_upload_timestamp'), table_name='frequency_scan_files') 39 | op.drop_index(op.f('ix_frequency_scan_files_receiver_id'), table_name='frequency_scan_files') 40 | op.drop_table('frequency_scan_files') 41 | # ### end Alembic commands ### 42 | -------------------------------------------------------------------------------- /app/templates/sender_ranking.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 |
7 |

Sender Ranking

8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for entry in ranking %} 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {% endfor %} 34 | 35 |
RankNameAircraftMaximum distance [km]Receiver counterCoverage counterMessage counter
{{ loop.index }}{{ entry.sender|to_html_flag|safe }}{{ entry.sender|to_html_link|safe }}{% if entry.sender.infos|length > 0 %}{{ entry.sender.infos[0].aircraft }}{% else %}-{% endif %}{{ '%0.1f' | format(entry.max_distance/1000.0) }}{{ entry.receivers_count }}{{ entry.coverages_count }}{{ entry.messages_count }}
36 | 37 |
38 |
39 | 40 | {% endblock %} 41 | 42 | {% block scripts %} 43 | {{ super() }} 44 | 54 | {% endblock %} 55 | -------------------------------------------------------------------------------- /migrations/versions/0dff4f629978_added_receiverstatusstatistic.py: -------------------------------------------------------------------------------- 1 | """Added ReceiverStatusStatistic 2 | 3 | Revision ID: 0dff4f629978 4 | Revises: 7f5b8f65a977 5 | Create Date: 2020-12-04 18:36:12.884785 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '0dff4f629978' 14 | down_revision = '7f5b8f65a977' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('receiver_status_statistics', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('date', sa.Date(), nullable=False), 24 | sa.Column('version', sa.String(), nullable=False), 25 | sa.Column('platform', sa.String(), nullable=False), 26 | sa.Column('messages_count', sa.Integer(), nullable=True), 27 | sa.Column('receiver_id', sa.Integer(), nullable=True), 28 | sa.ForeignKeyConstraint(['receiver_id'], ['receivers.id'], ondelete='CASCADE'), 29 | sa.PrimaryKeyConstraint('id') 30 | ) 31 | op.create_index('idx_receiver_status_statistics_uc', 'receiver_status_statistics', ['date', 'receiver_id', 'version', 'platform'], unique=True) 32 | op.create_index(op.f('ix_receiver_status_statistics_receiver_id'), 'receiver_status_statistics', ['receiver_id'], unique=False) 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade(): 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.drop_index(op.f('ix_receiver_status_statistics_receiver_id'), table_name='receiver_status_statistics') 39 | op.drop_index('idx_receiver_status_statistics_uc', table_name='receiver_status_statistics') 40 | op.drop_table('receiver_status_statistics') 41 | # ### end Alembic commands ### 42 | -------------------------------------------------------------------------------- /app/model/sender_info.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | from .sender_info_origin import SenderInfoOrigin 3 | from .aircraft_type import AircraftType 4 | 5 | #from sqlalchemy.dialects.postgresql import ENUM 6 | 7 | 8 | class SenderInfo(db.Model): 9 | __tablename__ = "sender_infos" 10 | 11 | id = db.Column(db.Integer, primary_key=True) 12 | address = db.Column(db.String(6), index=True) 13 | address_type = db.Column(db.String) 14 | aircraft = db.Column(db.String) 15 | registration = db.Column(db.String(7)) 16 | competition = db.Column(db.String(3)) 17 | tracked = db.Column(db.Boolean) 18 | identified = db.Column(db.Boolean) 19 | aircraft_type = db.Column(db.Enum(AircraftType), nullable=False, default=AircraftType.UNKNOWN) 20 | 21 | address_origin = db.Column(db.Enum(SenderInfoOrigin), nullable=False, default=SenderInfoOrigin.UNKNOWN) 22 | 23 | # Relations 24 | sender_id = db.Column(db.Integer, db.ForeignKey("senders.id"), index=True) 25 | sender = db.relationship("Sender", foreign_keys=[sender_id], backref=db.backref("infos", order_by=address_origin)) 26 | 27 | country_id = db.Column(db.Integer, db.ForeignKey("countries.gid"), index=True) 28 | country = db.relationship("Country", foreign_keys=[country_id], backref=db.backref("sender_infos", order_by=address_origin)) 29 | 30 | __table_args__ = (db.Index('idx_sender_infos_address_address_origin_uc', 'address', 'address_origin', unique=True), ) 31 | 32 | def __repr__(self): 33 | return "" % ( 34 | self.address_type, 35 | self.address, 36 | self.aircraft, 37 | self.registration, 38 | self.competition, 39 | self.tracked, 40 | self.identified, 41 | self.aircraft_type, 42 | self.address_origin, 43 | ) 44 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask import Flask 4 | from flask_bootstrap import Bootstrap 5 | from flask_sqlalchemy import SQLAlchemy 6 | from flask_migrate import Migrate 7 | from flask_caching import Cache 8 | from celery import Celery 9 | from flask_redis import FlaskRedis 10 | from flask_profiler import Profiler 11 | 12 | from config import configs 13 | 14 | bootstrap = Bootstrap() 15 | db = SQLAlchemy() 16 | migrate = Migrate() 17 | cache = Cache() 18 | redis_client = FlaskRedis() 19 | celery = Celery() 20 | profiler = Profiler() 21 | 22 | 23 | def create_app(config_name='default'): 24 | # Initialize Flask 25 | app = Flask(__name__) 26 | 27 | # Load the configuration 28 | configuration = configs[config_name] 29 | app.config.from_object(configuration) 30 | app.config.from_envvar("OGN_CONFIG_MODULE", silent=True) 31 | 32 | # Initialize other things 33 | bootstrap.init_app(app) 34 | db.init_app(app) 35 | migrate.init_app(app, db) 36 | cache.init_app(app) 37 | redis_client.init_app(app) 38 | profiler.init_app(app) 39 | 40 | init_celery(app) 41 | register_blueprints(app) 42 | 43 | return app 44 | 45 | 46 | def register_blueprints(app): 47 | from app.main import bp as bp_main 48 | app.register_blueprint(bp_main) 49 | 50 | 51 | def init_celery(app=None): 52 | app = app or create_app(os.getenv('FLASK_CONFIG') or 'default') 53 | celery.conf.broker_url = app.config['BROKER_URL'] 54 | celery.conf.result_backend = app.config['CELERY_RESULT_BACKEND'] 55 | celery.conf.update(app.config) 56 | 57 | class ContextTask(celery.Task): 58 | """Make celery tasks work with Flask app context""" 59 | def __call__(self, *args, **kwargs): 60 | with app.app_context(): 61 | return self.run(*args, **kwargs) 62 | 63 | celery.Task = ContextTask 64 | return celery 65 | -------------------------------------------------------------------------------- /app/templates/receivers.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 |
7 |

Receivers

8 |
9 | 10 |
11 |
12 | 18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | {% for receiver in receivers %} 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | {% endfor %} 48 | 49 |
#CountryNameAirportAltitudeStatusVersionPlatform
{{ loop.index }}{{ receiver|to_html_flag|safe }}{{ receiver|to_html_link|safe }}{{ receiver.airport|to_html_link|safe }}{{ receiver.altitude|int }} m{{ receiver.state.name }}{{ receiver.version if receiver.version else '-' }}{{ receiver.platform if receiver.platform else '-' }}
50 |
51 |
52 | 53 | {% endblock %} 54 | 55 | {% block scripts %} 56 | {{ super() }} 57 | 62 | {% endblock %} 63 | -------------------------------------------------------------------------------- /tests/gateway/beacon_data/valid_beacon_data/receiver.txt: -------------------------------------------------------------------------------- 1 | # With OGN software 0.2.7 receivers have the dstcall "OGNSDR" 2 | # 3 | LILH>OGNSDR,TCPIP*,qAC,GLIDERN2:/132201h4457.61NI00900.58E&/A=000423 4 | LILH>OGNSDR,TCPIP*,qAC,GLIDERN2:>132201h v0.2.7.RPI-GPU CPU:0.7 RAM:770.2/968.2MB NTP:1.8ms/-3.3ppm +55.7C 7/8Acfts[1h] RF:+54-1.1ppm/-0.16dB/+7.1dB@10km[19481]/+16.8dB@10km[7/13] 5 | MontCAIO>OGNSDR,TCPIP*,qAC,GLIDERN3:/132231h4427.84NI01009.60E&/A=004822 6 | MontCAIO>OGNSDR,TCPIP*,qAC,GLIDERN3:>132231h v0.2.7.RPI-GPU CPU:0.8 RAM:747.0/970.5MB NTP:2.8ms/-1.0ppm +73.1C 5/5Acfts[1h] RF:+69+1.3ppm/+3.53dB/+16.9dB@10km[7697]/+23.7dB@10km[3/6] 7 | Padova>OGNSDR,TCPIP*,qAC,GLIDERN1:/132326h4525.38NI01156.29E&/A=000069 8 | Padova>OGNSDR,TCPIP*,qAC,GLIDERN1:>132326h v0.2.7.RPI-GPU CPU:0.5 RAM:605.1/970.5MB NTP:0.5ms/-2.0ppm +65.5C 1/1Acfts[1h] RF:+0-1.1ppm/+13.97dB/+17.1dB@10km[6524]/+19.9dB@10km[5/9] 9 | LIDH>OGNSDR,TCPIP*,qAC,GLIDERN1:/132447h4540.89NI01129.65E&/A=000328 10 | LIDH>OGNSDR,TCPIP*,qAC,GLIDERN1:>132447h v0.2.7.RPI-GPU CPU:0.4 RAM:593.4/970.5MB NTP:3.7ms/-7.6ppm +67.7C 5/5Acfts[1h] RF:+61+1.0ppm/+12.63dB/+3.7dB@10km[27143]/+3.3dB@10km[3/6] 11 | LZHL>OGNSDR,TCPIP*,qAC,GLIDERN3:/132457h4849.09NI01708.30E&/A=000528 12 | LZHL>OGNSDR,TCPIP*,qAC,GLIDERN3:>132457h v0.2.7.arm CPU:0.9 RAM:75.3/253.6MB NTP:2.0ms/-15.2ppm +0.1C 2/2Acfts[1h] RF:+77+1.7ppm/+2.34dB/+6.5dB@10km[5411]/+10.1dB@10km[3/5] 13 | BELG>OGNSDR,TCPIP*,qAC,GLIDERN3:/132507h4509.60NI00919.20E&/A=000246 14 | BELG>OGNSDR,TCPIP*,qAC,GLIDERN3:>132507h v0.2.7.RPI-GPU CPU:1.2 RAM:35.7/455.2MB NTP:2.5ms/-5.3ppm +67.0C 1/1Acfts[1h] RF:+79+8.8ppm/+4.97dB/-0.0dB@10km[299]/+4.9dB@10km[2/3] 15 | Saleve>OGNSDR,TCPIP*,qAC,GLIDERN1:/132624h4607.70NI00610.41E&/A=004198 Antenna: chinese, on a pylon, 20 meter above ground 16 | Saleve>OGNSDR,TCPIP*,qAC,GLIDERN1:>132624h v0.2.7.arm CPU:1.7 RAM:812.3/1022.5MB NTP:1.8ms/+4.5ppm 0.000V 0.000A 3/4Acfts[1h] RF:+67+2.9ppm/+4.18dB/+11.7dB@10km[5018]/+17.2dB@10km[8/16] -------------------------------------------------------------------------------- /migrations/versions/eb571174e4b2_fix_senderpositionstatistic.py: -------------------------------------------------------------------------------- 1 | """Fix SenderPositionStatistic 2 | 3 | Revision ID: eb571174e4b2 4 | Revises: 0dff4f629978 5 | Create Date: 2020-12-04 19:09:41.947668 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'eb571174e4b2' 14 | down_revision = '0dff4f629978' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('sender_position_statistics', sa.Column('sender_id', sa.Integer(), nullable=True)) 22 | op.create_index(op.f('ix_sender_position_statistics_sender_id'), 'sender_position_statistics', ['sender_id'], unique=False) 23 | op.drop_index('idx_sender_position_statistics_uc', table_name='sender_position_statistics') 24 | op.create_index('idx_sender_position_statistics_uc', 'sender_position_statistics', ['date', 'sender_id', 'dstcall', 'address_type', 'aircraft_type', 'stealth', 'software_version', 'hardware_version'], unique=True) 25 | op.create_foreign_key(None, 'sender_position_statistics', 'senders', ['sender_id'], ['id'], ondelete='CASCADE') 26 | # ### end Alembic commands ### 27 | 28 | 29 | def downgrade(): 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | op.drop_constraint(None, 'sender_position_statistics', type_='foreignkey') 32 | op.drop_index('idx_sender_position_statistics_uc', table_name='sender_position_statistics') 33 | op.create_index('idx_sender_position_statistics_uc', 'sender_position_statistics', ['date', 'dstcall', 'address_type', 'aircraft_type', 'stealth', 'software_version', 'hardware_version'], unique=True) 34 | op.drop_index(op.f('ix_sender_position_statistics_sender_id'), table_name='sender_position_statistics') 35 | op.drop_column('sender_position_statistics', 'sender_id') 36 | # ### end Alembic commands ### 37 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | from datetime import date 4 | 5 | from app.model import AircraftType 6 | from app.utils import get_days, get_trackable, get_airports 7 | from app.commands.database import read_ddb 8 | 9 | 10 | class TestStringMethods(unittest.TestCase): 11 | def test_get_days(self): 12 | start = date(2018, 2, 27) 13 | end = date(2018, 3, 2) 14 | days = get_days(start, end) 15 | self.assertEqual(days, [date(2018, 2, 27), date(2018, 2, 28), date(2018, 3, 1), date(2018, 3, 2)]) 16 | 17 | def test_get_devices(self): 18 | sender_infos = read_ddb() 19 | self.assertGreater(len(sender_infos), 1000) 20 | 21 | def test_get_ddb_from_file(self): 22 | sender_infos = read_ddb(os.path.dirname(__file__) + "/custom_ddb.txt") 23 | self.assertEqual(len(sender_infos), 6) 24 | sender_info = sender_infos[0] 25 | 26 | self.assertEqual(sender_info['address'], "DD4711") 27 | self.assertEqual(sender_info['aircraft'], "HK36 TTC") 28 | self.assertEqual(sender_info['registration'], "D-EULE") 29 | self.assertEqual(sender_info['competition'], "CU") 30 | self.assertTrue(sender_info['tracked']) 31 | self.assertTrue(sender_info['identified']) 32 | self.assertEqual(sender_info['aircraft_type'], AircraftType.GLIDER_OR_MOTOR_GLIDER) 33 | 34 | def test_get_trackable(self): 35 | sender_infos = read_ddb(os.path.dirname(__file__) + "/custom_ddb.txt") 36 | trackable = get_trackable(sender_infos) 37 | self.assertEqual(len(trackable), 4) 38 | self.assertIn("FLRDD4711", trackable) 39 | self.assertIn("FLRDD0815", trackable) 40 | self.assertIn("OGNDEADBE", trackable) 41 | self.assertIn("ICA999999", trackable) 42 | 43 | def test_get_airports(self): 44 | airports = get_airports(os.path.dirname(__file__) + "/SeeYou.cup") 45 | self.assertGreater(len(airports), 1000) 46 | -------------------------------------------------------------------------------- /app/static/ognlive/horizZoomControl.js: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * The ZoomControl adds horizontal [+/-] buttons for the map 4 | * to replace the standard vertical one 5 | */ 6 | 7 | function ZoomControl(controlDiv, map) { 8 | 9 | // Creating divs & styles for custom zoom control 10 | controlDiv.style.padding = '10px'; 11 | 12 | // Set CSS for the control wrapper 13 | var controlWrapper = document.createElement('div'); 14 | controlWrapper.style.cursor = 'pointer'; 15 | controlWrapper.style.width = '56px'; 16 | controlWrapper.style.height = '28px'; 17 | controlWrapper.style.backgroundImage = 'url("horizZoom.png")'; 18 | controlDiv.appendChild(controlWrapper); 19 | 20 | // Set CSS for the zoomIn 21 | var zoomInButton = document.createElement('div'); 22 | zoomInButton.style.float = "left"; 23 | zoomInButton.style.width = '28px'; 24 | zoomInButton.style.height = '28px'; 25 | controlWrapper.appendChild(zoomInButton); 26 | 27 | // Set CSS for the zoomOut 28 | var zoomOutButton = document.createElement('div'); 29 | zoomOutButton.style.float = "left"; 30 | zoomOutButton.style.width = '28px'; 31 | zoomOutButton.style.height = '28px'; 32 | controlWrapper.appendChild(zoomOutButton); 33 | 34 | // Setup the click event listener - zoomIn 35 | google.maps.event.addDomListener(zoomInButton, 'click', function() { 36 | map.setZoom(map.getZoom() + 1); 37 | }); 38 | 39 | // Setup the click event listener - zoomOut 40 | google.maps.event.addDomListener(zoomOutButton, 'click', function() { 41 | map.setZoom(map.getZoom() - 1); 42 | }); 43 | 44 | } 45 | 46 | function horizZoomControl_initialize() { 47 | // Create the DIV to hold the control and call the ZoomControl() constructor 48 | // passing in this DIV. 49 | var zoomControlDiv = document.createElement('div'); 50 | var zoomControl = new ZoomControl(zoomControlDiv, map); 51 | 52 | zoomControlDiv.index = 1; 53 | map.controls[google.maps.ControlPosition.TOP_CENTER].push(zoomControlDiv); 54 | } -------------------------------------------------------------------------------- /tests/gateway/valid_messages/OGSPID_Spider.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for Spider APRS format 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | # id3003... is the unique identifier from the SPIDER server 5 | # LWE is the registration within the SPIDER system 6 | # 3D is the quality of the signal 3D vs. 2D 7 | # 8 | FLRDDF944>OGSPID,qAS,SPIDER:/190930h3322.78S/07034.60W'000/000/A=002263 id300234010617040 +19dB LWE 3D 9 | FLRDDF944>OGSPID,qAS,SPIDER:/190930h3322.78S/07034.60W'000/000/A=002263 id300234010617040 +19dB LWE 3D 10 | FLRDDF944>OGSPID,qAS,SPIDER:/192430h3322.78S/07034.61W'000/000/A=002250 id300234010617040 +12dB LWE 3D 11 | FLRDDF944>OGSPID,qAS,SPIDER:/193930h3322.10S/07034.26W'273/027/A=004071 id300234010617040 +9dB LWE 3D 12 | FLRDDF944>OGSPID,qAS,SPIDER:/195430h3322.82S/07034.90W'000/000/A=002217 id300234010617040 +10dB LWE 3D 13 | FLRDDF944>OGSPID,qAS,SPIDER:/193930h3322.78S/07034.60W'348/000/A=002286 id300234010617040 +12dB LWE 3D 14 | FLRDDF944>OGSPID,qAS,SPIDER:/195430h3323.16S/07037.68W'302/034/A=003316 id300234010617040 +10dB LWE 3D 15 | FLRDDF944>OGSPID,qAS,SPIDER:/195430h3323.16S/07037.68W'302/034/A=003316 id300234010617040 +10dB LWE 3D 16 | FLRDDF944>OGSPID,qAS,SPIDER:/200930h3319.13S/07036.12W'128/031/A=005482 id300234010617040 +15dB LWE 3D 17 | FLRDDF944>OGSPID,qAS,SPIDER:/200930h3319.13S/07036.12W'128/031/A=005482 id300234010617040 +15dB LWE 3D 18 | FLRDDF944>OGSPID,qAS,SPIDER:/202430h3314.92S/07032.08W'138/032/A=006453 id300234010617040 +9dB LWE 3D 19 | FLRDDF944>OGSPID,qAS,SPIDER:/203930h3321.38S/07027.29W'104/034/A=006272 id300234010617040 +8dB LWE 3D 20 | FLRDDF944>OGSPID,qAS,SPIDER:/205430h3322.13S/07033.53W'296/031/A=003927 id300234010617040 +7dB LWE 3D 21 | FLRDDF944>OGSPID,qAS,SPIDER:/210930h3322.05S/07035.74W'165/030/A=005187 id300234010617040 +8dB LWE 3D 22 | FLRDDF944>OGSPID,qAS,SPIDER:/212430h3322.02S/07036.14W'281/028/A=004550 id300234010617040 +7dB LWE 3D 23 | FLRDDF944>OGSPID,qAS,SPIDER:/213930h3322.17S/07033.97W'332/028/A=003428 id300234010617040 +7dB LWE 3D 24 | -------------------------------------------------------------------------------- /tests/gateway/beacon_data/valid_beacon_data/spider.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for Spider APRS format 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | # id3003... is the unique identifier from the SPIDER server 5 | # LWE is the registration within the SPIDER system 6 | # 3D is the quality of the signal 3D vs. 2D 7 | # 8 | FLRDDF944>OGSPID,qAS,SPIDER:/190930h3322.78S/07034.60W'000/000/A=002263 id300234010617040 +19dB LWE 3D 9 | FLRDDF944>OGSPID,qAS,SPIDER:/190930h3322.78S/07034.60W'000/000/A=002263 id300234010617040 +19dB LWE 3D 10 | FLRDDF944>OGSPID,qAS,SPIDER:/192430h3322.78S/07034.61W'000/000/A=002250 id300234010617040 +12dB LWE 3D 11 | FLRDDF944>OGSPID,qAS,SPIDER:/193930h3322.10S/07034.26W'273/027/A=004071 id300234010617040 +9dB LWE 3D 12 | FLRDDF944>OGSPID,qAS,SPIDER:/195430h3322.82S/07034.90W'000/000/A=002217 id300234010617040 +10dB LWE 3D 13 | FLRDDF944>OGSPID,qAS,SPIDER:/193930h3322.78S/07034.60W'348/000/A=002286 id300234010617040 +12dB LWE 3D 14 | FLRDDF944>OGSPID,qAS,SPIDER:/195430h3323.16S/07037.68W'302/034/A=003316 id300234010617040 +10dB LWE 3D 15 | FLRDDF944>OGSPID,qAS,SPIDER:/195430h3323.16S/07037.68W'302/034/A=003316 id300234010617040 +10dB LWE 3D 16 | FLRDDF944>OGSPID,qAS,SPIDER:/200930h3319.13S/07036.12W'128/031/A=005482 id300234010617040 +15dB LWE 3D 17 | FLRDDF944>OGSPID,qAS,SPIDER:/200930h3319.13S/07036.12W'128/031/A=005482 id300234010617040 +15dB LWE 3D 18 | FLRDDF944>OGSPID,qAS,SPIDER:/202430h3314.92S/07032.08W'138/032/A=006453 id300234010617040 +9dB LWE 3D 19 | FLRDDF944>OGSPID,qAS,SPIDER:/203930h3321.38S/07027.29W'104/034/A=006272 id300234010617040 +8dB LWE 3D 20 | FLRDDF944>OGSPID,qAS,SPIDER:/205430h3322.13S/07033.53W'296/031/A=003927 id300234010617040 +7dB LWE 3D 21 | FLRDDF944>OGSPID,qAS,SPIDER:/210930h3322.05S/07035.74W'165/030/A=005187 id300234010617040 +8dB LWE 3D 22 | FLRDDF944>OGSPID,qAS,SPIDER:/212430h3322.02S/07036.14W'281/028/A=004550 id300234010617040 +7dB LWE 3D 23 | FLRDDF944>OGSPID,qAS,SPIDER:/213930h3322.17S/07033.97W'332/028/A=003428 id300234010617040 +7dB LWE 3D 24 | -------------------------------------------------------------------------------- /tests/gateway/valid_messages/OGNSDR.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for the APRS format for OGN receivers 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | # With OGN software 0.2.7 receivers have the dstcall "OGNSDR" 5 | # 6 | LILH>OGNSDR,TCPIP*,qAC,GLIDERN2:/132201h4457.61NI00900.58E&/A=000423 7 | LILH>OGNSDR,TCPIP*,qAC,GLIDERN2:>132201h v0.2.7.RPI-GPU CPU:0.7 RAM:770.2/968.2MB NTP:1.8ms/-3.3ppm +55.7C 7/8Acfts[1h] RF:+54-1.1ppm/-0.16dB/+7.1dB@10km[19481]/+16.8dB@10km[7/13] 8 | MontCAIO>OGNSDR,TCPIP*,qAC,GLIDERN3:/132231h4427.84NI01009.60E&/A=004822 9 | MontCAIO>OGNSDR,TCPIP*,qAC,GLIDERN3:>132231h v0.2.7.RPI-GPU CPU:0.8 RAM:747.0/970.5MB NTP:2.8ms/-1.0ppm +73.1C 5/5Acfts[1h] RF:+69+1.3ppm/+3.53dB/+16.9dB@10km[7697]/+23.7dB@10km[3/6] 10 | Padova>OGNSDR,TCPIP*,qAC,GLIDERN1:/132326h4525.38NI01156.29E&/A=000069 11 | Padova>OGNSDR,TCPIP*,qAC,GLIDERN1:>132326h v0.2.7.RPI-GPU CPU:0.5 RAM:605.1/970.5MB NTP:0.5ms/-2.0ppm +65.5C 1/1Acfts[1h] RF:+0-1.1ppm/+13.97dB/+17.1dB@10km[6524]/+19.9dB@10km[5/9] 12 | LIDH>OGNSDR,TCPIP*,qAC,GLIDERN1:/132447h4540.89NI01129.65E&/A=000328 13 | LIDH>OGNSDR,TCPIP*,qAC,GLIDERN1:>132447h v0.2.7.RPI-GPU CPU:0.4 RAM:593.4/970.5MB NTP:3.7ms/-7.6ppm +67.7C 5/5Acfts[1h] RF:+61+1.0ppm/+12.63dB/+3.7dB@10km[27143]/+3.3dB@10km[3/6] 14 | LZHL>OGNSDR,TCPIP*,qAC,GLIDERN3:/132457h4849.09NI01708.30E&/A=000528 15 | LZHL>OGNSDR,TCPIP*,qAC,GLIDERN3:>132457h v0.2.7.arm CPU:0.9 RAM:75.3/253.6MB NTP:2.0ms/-15.2ppm +0.1C 2/2Acfts[1h] RF:+77+1.7ppm/+2.34dB/+6.5dB@10km[5411]/+10.1dB@10km[3/5] 16 | BELG>OGNSDR,TCPIP*,qAC,GLIDERN3:/132507h4509.60NI00919.20E&/A=000246 17 | BELG>OGNSDR,TCPIP*,qAC,GLIDERN3:>132507h v0.2.7.RPI-GPU CPU:1.2 RAM:35.7/455.2MB NTP:2.5ms/-5.3ppm +67.0C 1/1Acfts[1h] RF:+79+8.8ppm/+4.97dB/-0.0dB@10km[299]/+4.9dB@10km[2/3] 18 | Saleve>OGNSDR,TCPIP*,qAC,GLIDERN1:/132624h4607.70NI00610.41E&/A=004198 Antenna: chinese, on a pylon, 20 meter above ground 19 | Saleve>OGNSDR,TCPIP*,qAC,GLIDERN1:>132624h v0.2.7.arm CPU:1.7 RAM:812.3/1022.5MB NTP:1.8ms/+4.5ppm 0.000V 0.000A 3/4Acfts[1h] RF:+67+2.9ppm/+4.18dB/+11.7dB@10km[5018]/+17.2dB@10km[8/16] 20 | -------------------------------------------------------------------------------- /app/backend/ognrange.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, timedelta 3 | 4 | from app.model import Receiver, ReceiverCoverage 5 | 6 | from app import db 7 | 8 | 9 | def alchemyencoder(obj): 10 | """JSON encoder function for SQLAlchemy special classes.""" 11 | 12 | import decimal 13 | from datetime import datetime 14 | 15 | if isinstance(obj, datetime): 16 | return obj.strftime("%Y-%m-%d %H:%M") 17 | elif isinstance(obj, decimal.Decimal): 18 | return float(obj) 19 | 20 | 21 | def stations2_filtered_pl(start, end): 22 | last_10_minutes = datetime.utcnow() - timedelta(minutes=10) 23 | 24 | query = ( 25 | db.session.query( 26 | Receiver.name.label("s"), 27 | db.label("lt", db.func.round(db.func.ST_Y(Receiver.location_wkt) * 10000) / 10000), 28 | db.label("lg", db.func.round(db.func.ST_X(Receiver.location_wkt) * 10000) / 10000), 29 | db.case([(Receiver.lastseen > last_10_minutes, "U")], else_="D").label("u"), 30 | Receiver.lastseen.label("ut"), 31 | db.label("v", Receiver.version + "." + Receiver.platform), 32 | ) 33 | .order_by(Receiver.lastseen) 34 | .filter(db.or_(db.and_(start < Receiver.firstseen, end > Receiver.firstseen), db.and_(start < Receiver.lastseen, end > Receiver.lastseen))) 35 | ) 36 | 37 | res = db.session.execute(query) 38 | stations = json.dumps({"stations": [dict(r) for r in res]}, default=alchemyencoder) 39 | 40 | return stations 41 | 42 | 43 | def max_tile_mgrs_pl(station, start, end, squares): 44 | query = ( 45 | db.session.query(db.func.right(ReceiverCoverage.location_mgrs_short, 4), db.func.count(ReceiverCoverage.location_mgrs_short)) 46 | .filter(db.and_(Receiver.id == ReceiverCoverage.receiver_id, Receiver.name == station)) 47 | .filter(ReceiverCoverage.location_mgrs_short.like(squares + "%")) 48 | .group_by(db.func.right(ReceiverCoverage.location_mgrs_short, 4)) 49 | ) 50 | 51 | res = {"t": squares, "p": ["{}/{}".format(r[0], r[1]) for r in query.all()]} 52 | return json.dumps(res) 53 | -------------------------------------------------------------------------------- /app/gateway/beacon_conversion.py: -------------------------------------------------------------------------------- 1 | from flask import current_app 2 | 3 | from mgrs import MGRS 4 | 5 | from ogn.parser import parse 6 | 7 | from app.model import AircraftType 8 | 9 | #import rasterio as rs 10 | #elevation_dataset = rs.open('/Volumes/LaCieBlack/Wtf4.tiff') 11 | 12 | mgrs = MGRS() 13 | 14 | 15 | def aprs_string_to_message(aprs_string): 16 | try: 17 | message = parse(aprs_string, calculate_relations=True) 18 | except Exception as e: 19 | current_app.logger.debug(e) 20 | return None 21 | 22 | if message['aprs_type'] not in ('position', 'status'): 23 | return None 24 | 25 | elif message['aprs_type'] == 'position': 26 | latitude = message["latitude"] 27 | longitude = message["longitude"] 28 | 29 | message["location"] = "SRID=4326;POINT({} {})".format(longitude, latitude) 30 | 31 | location_mgrs = mgrs.toMGRS(latitude, longitude).decode("utf-8") 32 | message["location_mgrs"] = location_mgrs 33 | message["location_mgrs_short"] = location_mgrs[0:5] + location_mgrs[5:7] + location_mgrs[10:12] 34 | 35 | #if 'altitude' in message and longitude >= 0.0 and longitude <= 20.0 and latitude >= 40.0 and latitude <= 60.0: 36 | # elevation = [val[0] for val in elevation_dataset.sample(((longitude, latitude),))][0] 37 | # message['agl'] = message['altitude'] - elevation 38 | 39 | if 'bearing' in message: 40 | bearing = int(message['bearing']) 41 | message['bearing'] = bearing if bearing < 360 else 0 42 | 43 | if "aircraft_type" in message: 44 | message["aircraft_type"] = AircraftType(message["aircraft_type"]) if message["aircraft_type"] in AircraftType.list() else AircraftType.UNKNOWN 45 | 46 | if "gps_quality" in message: 47 | if message["gps_quality"] is not None and "horizontal" in message["gps_quality"]: 48 | message["gps_quality_horizontal"] = message["gps_quality"]["horizontal"] 49 | message["gps_quality_vertical"] = message["gps_quality"]["vertical"] 50 | del message["gps_quality"] 51 | 52 | return message 53 | -------------------------------------------------------------------------------- /tests/collect/test_takeoff_landing.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | from tests.base import TestBaseDB, db 5 | 6 | from app.model import TakeoffLanding 7 | 8 | from app.collect.logbook import update_takeoff_landings 9 | 10 | 11 | class TestTakeoffLanding(TestBaseDB): 12 | def test_broken_rope(self): 13 | """The algorithm should detect one takeoff and one landing.""" 14 | 15 | self.insert_airports_and_devices() 16 | self.insert_sender_positions_broken_rope() 17 | 18 | # find the takeoff and the landing 19 | update_takeoff_landings(start=datetime.datetime(2016, 7, 2, 0, 0, 0), end=datetime.datetime(2016, 7, 2, 23, 59, 59)) 20 | takeoff_landing_query = db.session.query(TakeoffLanding).filter(db.between(TakeoffLanding.timestamp, datetime.datetime(2016, 7, 2, 0, 0, 0), datetime.datetime(2016, 7, 2, 23, 59, 59))) 21 | 22 | self.assertEqual(len(takeoff_landing_query.all()), 2) 23 | for entry in takeoff_landing_query.all(): 24 | self.assertEqual(entry.airport.name, "Koenigsdorf") 25 | 26 | # we should not find the takeoff and the landing again 27 | update_takeoff_landings(start=datetime.datetime(2016, 7, 2, 0, 0, 0), end=datetime.datetime(2016, 7, 2, 23, 59, 59)) 28 | self.assertEqual(len(takeoff_landing_query.all()), 2) 29 | 30 | def test_broken_rope_with_stall(self): 31 | """Here we have a broken rope where the glider passes again the threshold for take off.""" 32 | 33 | self.insert_airports_and_devices() 34 | self.insert_sender_positions_broken_rope_with_stall() 35 | 36 | # find the takeoff and the landing 37 | update_takeoff_landings(start=datetime.datetime(2019, 4, 13, 0, 0, 0), end=datetime.datetime(2019, 4, 13, 23, 59, 59)) 38 | takeoff_landings = db.session.query(TakeoffLanding).filter(db.between(TakeoffLanding.timestamp, datetime.datetime(2019, 4, 13, 0, 0, 0), datetime.datetime(2019, 4, 13, 23, 59, 59))).all() 39 | 40 | self.assertEqual(len(takeoff_landings), 2) 41 | for entry in takeoff_landings: 42 | self.assertEqual(entry.airport.name, "Koenigsdorf") 43 | 44 | 45 | if __name__ == "__main__": 46 | unittest.main() 47 | -------------------------------------------------------------------------------- /app/model/sender.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from sqlalchemy.ext.hybrid import hybrid_property 4 | 5 | from app import db 6 | from app.model.aircraft_type import AircraftType 7 | 8 | 9 | class Sender(db.Model): 10 | __tablename__ = "senders" 11 | 12 | id = db.Column(db.Integer, primary_key=True) 13 | name = db.Column(db.String) 14 | 15 | address = db.Column(db.String(6), index=True) 16 | firstseen = db.Column(db.DateTime, index=True) 17 | lastseen = db.Column(db.DateTime, index=True) 18 | aircraft_type = db.Column(db.Enum(AircraftType), nullable=False, default=AircraftType.UNKNOWN) 19 | stealth = db.Column(db.Boolean) 20 | software_version = db.Column(db.Float(precision=2)) 21 | hardware_version = db.Column(db.SmallInteger) 22 | real_address = db.Column(db.String(6)) 23 | 24 | __table_args__ = (db.Index('idx_senders_name_uc', 'name', unique=True), ) 25 | 26 | def __repr__(self): 27 | return "" % (self.address, self.aircraft_type, self.stealth, self.software_version, self.hardware_version, self.real_address) 28 | 29 | EXPIRY_DATES = { 30 | 7.01: datetime.date(2022, 2, 28), 31 | 7.0: datetime.date(2021, 10, 31), 32 | 6.83: datetime.date(2021, 10, 31), 33 | 6.82: datetime.date(2021, 5, 31), 34 | 6.81: datetime.date(2021, 1, 31), 35 | 6.80: datetime.date(2021, 1, 31), 36 | 6.67: datetime.date(2020, 10, 31), 37 | 6.63: datetime.date(2020, 5, 31), 38 | 6.62: datetime.date(2020, 5, 31), 39 | 6.6: datetime.date(2020, 1, 31), 40 | 6.42: datetime.date(2019, 10, 31), 41 | 6.41: datetime.date(2019, 1, 31), 42 | 6.4: datetime.date(2019, 1, 31), 43 | 6.09: datetime.date(2018, 9, 30), 44 | 6.08: datetime.date(2018, 9, 30), 45 | 6.07: datetime.date(2018, 3, 31), 46 | 6.06: datetime.date(2017, 9, 30), 47 | 6.05: datetime.date(2017, 3, 31), 48 | } 49 | 50 | def expiry_date(self): 51 | if self.name.startswith("FLR"): 52 | if self.software_version in self.EXPIRY_DATES: 53 | return self.EXPIRY_DATES[self.software_version] 54 | else: 55 | return datetime.date(2000, 1, 1) 56 | else: 57 | return None 58 | -------------------------------------------------------------------------------- /app/model/sender_position.py: -------------------------------------------------------------------------------- 1 | from geoalchemy2.types import Geometry 2 | from app import db 3 | 4 | from .aircraft_type import AircraftType 5 | 6 | 7 | class SenderPosition(db.Model): 8 | __tablename__ = "sender_positions" 9 | 10 | reference_timestamp = db.Column(db.DateTime, primary_key=True) 11 | 12 | # APRS data 13 | name = db.Column(db.String) 14 | dstcall = db.Column(db.String) 15 | relay = db.Column(db.String) 16 | receiver_name = db.Column(db.String(9)) 17 | timestamp = db.Column(db.DateTime) 18 | location = db.Column("location", Geometry("POINT", srid=4326)) 19 | symboltable = None 20 | symbolcode = None 21 | 22 | track = db.Column(db.SmallInteger) 23 | ground_speed = db.Column(db.Float(precision=2)) 24 | altitude = db.Column(db.Float(precision=2)) 25 | 26 | comment = None 27 | 28 | # Type information 29 | beacon_type = None 30 | aprs_type = None 31 | 32 | # Debug information 33 | raw_message = None 34 | 35 | # Flarm specific data 36 | address_type = db.Column(db.SmallInteger) 37 | aircraft_type = db.Column(db.Enum(AircraftType), nullable=False, default=AircraftType.UNKNOWN) 38 | stealth = db.Column(db.Boolean) 39 | address = db.Column(db.String) 40 | climb_rate = db.Column(db.Float(precision=2)) 41 | turn_rate = db.Column(db.Float(precision=2)) 42 | signal_quality = db.Column(db.Float(precision=2)) 43 | error_count = db.Column(db.SmallInteger) 44 | frequency_offset = db.Column(db.Float(precision=2)) 45 | gps_quality_horizontal = db.Column(db.SmallInteger) 46 | gps_quality_vertical = db.Column(db.SmallInteger) 47 | software_version = db.Column(db.Float(precision=2)) 48 | hardware_version = db.Column(db.SmallInteger) 49 | real_address = db.Column(db.String(6)) 50 | signal_power = db.Column(db.Float(precision=2)) 51 | 52 | #proximity = None 53 | 54 | # Calculated values (from parser) 55 | distance = db.Column(db.Float(precision=2)) 56 | bearing = db.Column(db.SmallInteger) 57 | normalized_quality = db.Column(db.Float(precision=2)) # signal quality normalized to 10km 58 | 59 | # Calculated values (from this software) 60 | location_mgrs = db.Column(db.String(15)) # full mgrs (15 chars) 61 | location_mgrs_short = db.Column(db.String(9)) # reduced mgrs (9 chars), e.g. used for melissas range tool 62 | agl = db.Column(db.Float(precision=2)) 63 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from os import path 4 | from setuptools import setup, find_packages 5 | 6 | 7 | here = path.abspath(path.dirname(__file__)) 8 | 9 | # Get the long description from the README file 10 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 11 | long_description = f.read() 12 | 13 | setup( 14 | name='ogn-python', 15 | version='0.5.0', 16 | description='A database backend for the Open Glider Network', 17 | long_description=long_description, 18 | url='https://github.com/glidernet/ogn-python', 19 | author='Konstantin Gründger aka Meisterschueler, Fabian P. Schmidt aka kerel, Dominic Spreitz', 20 | author_email='kerel-fs@gmx.de', 21 | license='AGPLv3', 22 | classifiers=[ 23 | 'Development Status :: 3 - Alpha', 24 | 'Intended Audience :: Developers', 25 | 'Intended Audience :: Science/Research', 26 | 'Topic :: Scientific/Engineering :: GIS', 27 | 'License :: OSI Approved :: GNU Affero General Public License v3', 28 | 'Programming Language :: Python :: 3', 29 | 'Programming Language :: Python :: 3.5', 30 | 'Programming Language :: Python :: 3.6', 31 | 'Programming Language :: Python :: 3.7', 32 | 'Programming Language :: Python :: 3.8', 33 | 'Programming Language :: Python :: 3.9-dev' 34 | ], 35 | keywords='gliding ogn', 36 | packages=find_packages(exclude=['tests', 'tests.*']), 37 | install_requires=[ 38 | 'Flask==2.0.2', 39 | 'Flask-SQLAlchemy==2.5.1', 40 | 'Flask-Migrate==3.1.0', 41 | 'Flask-Bootstrap==3.3.7.1', 42 | 'Flask-WTF==0.15.1', 43 | 'Flask-Caching==1.10.1', 44 | 'Flask-Profiler==1.8.1', 45 | 'geopy==2.1.0', 46 | 'celery==4.4.7', 47 | 'Flask-Redis==0.4.0', 48 | 'redis==3.5.3', 49 | 'aerofiles==1.0.0', 50 | 'geoalchemy2==0.9.0', 51 | 'shapely==1.7.1', 52 | 'ogn-client==1.2.1', 53 | 'mgrs==1.4.2', 54 | 'psycopg2-binary==2.9.2', 55 | 'xmlunittest==0.5.0', 56 | 'flower==0.9.7', 57 | 'tqdm==4.62.3', 58 | 'requests==2.25.1', 59 | 'matplotlib==3.5.1', 60 | 'bokeh==2.4.2', 61 | 'pandas==1.3.5', 62 | 'flydenity==0.1.6', 63 | 'gunicorn==20.1.0' 64 | ], 65 | test_require=[ 66 | 'pytest==5.0.1', 67 | 'flake8==1.1.1', 68 | 'xmlunittest==0.4.0', 69 | ], 70 | zip_safe=False 71 | ) 72 | -------------------------------------------------------------------------------- /app/templates/airport_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 | 7 |
8 |

Airport Details

9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
Name:{{ airport|to_html_flag|safe }}{{ airport.name }}
Code:{{ airport.code }}
Altitude:{{ airport.altitude|int }} m
Style:{{ airport.style }}
Description:{{ airport.description }}
Runway Direction:{{ airport.runway_direction }}
Runway Length:{{ airport.runway_length }} m
Frequency:{{ airport.frequency }} MHz
19 |
20 | 21 |
22 |

Receivers

23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {% for receiver in airport.receivers %} 32 | 33 | 34 | 35 | 36 | 37 | 38 | {% endfor %} 39 |
NameStatusVersionPlatform
{{ receiver.name }}{{ receiver.state.name }}{{ receiver.version if receiver.version else '-' }}{{ receiver.platform if receiver.platform else '-' }}
40 |
41 | 42 |
43 |

Seen Senders

44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | {% for sender in senders %} 53 | 54 | 55 | 57 | 58 | 59 | {% endfor %} 60 |
NameLast takeoff/landingHardware versionSoftware version
{{ sender|to_html_link|safe }}{% if sender.takeoff_landings %}{% set last_action = sender.takeoff_landings|last %}{% if last_action.is_takeoff %}↗{% else %}↘{% endif %} @ {{ last_action.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{% endif %} 56 | {% if sender.hardware_version is not none %}{{ sender.hardware_version }}{% else %}-{% endif %}{% if sender.software_version is not none %}{{ sender.software_version }}{% else %}-{% endif %}
61 |
62 | 63 |
64 | 65 | {% endblock %} -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class BaseConfig: 5 | SECRET_KEY = "i-like-ogn" 6 | 7 | # Flask-Cache stuff 8 | CACHE_TYPE = "redis" 9 | CACHE_DEFAULT_TIMEOUT = 300 10 | 11 | # Redis stuff 12 | REDIS_URL = "redis://localhost:6379/0" 13 | 14 | # Celery stuff 15 | BROKER_URL = os.environ.get("BROKER_URL", REDIS_URL) 16 | CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", REDIS_URL) 17 | 18 | APRS_USER = "OGNPYTHON" 19 | 20 | # Upload configuration 21 | MAX_CONTENT_LENGTH = 1024 * 1024 # max. 1MB 22 | UPLOAD_EXTENSIONS = ['.csv'] 23 | UPLOAD_PATH = 'uploads' 24 | 25 | 26 | class DefaultConfig(BaseConfig): 27 | SQLALCHEMY_DATABASE_URI = os.environ.get("SQLALCHEMY_DATABASE_URI", "postgresql://postgres:postgres@localhost:5432/ogn") 28 | SQLALCHEMY_TRACK_MODIFICATIONS = False 29 | 30 | # Celery beat stuff 31 | from celery.schedules import crontab 32 | from datetime import timedelta 33 | 34 | CELERYBEAT_SCHEDULE = { 35 | "transfer_to_database": {"task": "transfer_to_database", "schedule": timedelta(minutes=1)}, 36 | "update_statistics": {"task": "update_statistics", "schedule": timedelta(minutes=5)}, 37 | "update_takeoff_landings": {"task": "update_takeoff_landings", "schedule": timedelta(minutes=1), "kwargs": {"last_minutes": 20}}, 38 | "update_logbook": {"task": "update_logbook", "schedule": timedelta(minutes=1)}, 39 | "update_logbook_previous_day": {"task": "update_logbook", "schedule": crontab(hour=1, minute=0), "kwargs": {"day_offset": -1}}, 40 | 41 | "update_ddb_daily": {"task": "import_ddb", "schedule": timedelta(days=1)}, 42 | #"update_logbook_max_altitude": {"task": "update_logbook_max_altitude", "schedule": timedelta(minutes=1), "kwargs": {"offset_days": 0}}, 43 | 44 | #"purge_old_data": {"task": "purge_old_data", "schedule": timedelta(hours=1), "kwargs": {"max_hours": 48}}, 45 | } 46 | 47 | FLASK_PROFILER = { 48 | "enabled": True, 49 | "storage": { 50 | "engine": "sqlalchemy", 51 | "db_url": SQLALCHEMY_DATABASE_URI 52 | }, 53 | "ignore": [ 54 | "^/static/.*" 55 | ] 56 | } 57 | 58 | 59 | class DevelopmentConfig(BaseConfig): 60 | SQLALCHEMY_DATABASE_URI = "postgresql://postgres:postgres@localhost:5432/ogn_test" 61 | SQLALCHEMY_TRACK_MODIFICATIONS = False 62 | SQLALCHEMY_ECHO = False 63 | 64 | 65 | configs = { 66 | 'default': DefaultConfig, 67 | 'development': DevelopmentConfig, 68 | 'testing': DevelopmentConfig 69 | } 70 | -------------------------------------------------------------------------------- /app/templates/receiver_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 | 7 |
8 |

Receiver Details

9 | 10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
Name:{{ receiver|to_html_flag|safe }}{{ receiver.name }}
Airport:{% if receiver.airport is not none %}{{ receiver.airport|to_html_flag|safe }} 13 | {{ receiver.airport.name }} 14 | {% else %}-{% endif %} 15 |
Altitude:{{ receiver.altitude|int }}m
AGL:{{ receiver.agl|int }}m
Version:{{ receiver.version if receiver.version else '-' }}
Platform:{{ receiver.platform if receiver.platform else '-' }}
First seen:{{ receiver.firstseen }}
Last seen:{{ receiver.lastseen }}
State:{{ receiver.state.name }}
25 |
26 | 27 |
28 |

Airport nearby

29 | 30 | 31 | 32 | 33 | 34 | 35 | {% for (airport,distance,azimuth) in receiver.airports_nearby() %} 36 | 37 | 38 | 39 | 40 | 41 | {% endfor %} 42 |
#NameDistance [km]
{{ loop.index }}{% if airport.takeoff_landings|length > 0 %}{{ airport|to_html_link|safe }}{% else %}{{ airport.name }}{% endif %}{{ '%0.1f' | format(distance/1000.0) }} ({{ azimuth|to_ordinal }})
43 |
44 | 45 | {% if receiver.frequency_scan_files %} 46 |
47 |

Frequency Scans

48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | {% for file in receiver.frequency_scan_files %} 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | {% endfor %} 65 |
#NameGainUpload TimestampAnalysis
{{ loop.index }}{{ file.name }}{{ file.gain }}{{ file.upload_timestamp }}Plot
66 |
67 | {% endif %} 68 | 69 |
70 | 71 | {% endblock %} -------------------------------------------------------------------------------- /tests/gateway/beacon_data/valid_beacon_data/flymaster.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for Flymaster APRS format 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | FMT924469>OGFLYM,qAS,FLYMASTER:/155232h3720.70N/00557.97W^222/092/A=000029 !W52! 5 | FMT003549>OGFLYM,qAS,FLYMASTER:/155231h3751.35N/00126.13W^270/022/A=001430 !W14! 6 | FMT001300>OGFLYM,qAS,FLYMASTER:/155249h3706.99N/00807.27W^178/000/A=000131 !W86! 7 | FMT798890>OGFLYM,qAS,FLYMASTER:/155256h3720.49N/00558.27W^234/086/A=000009 !W00! 8 | FMT549112>OGFLYM,qAS,FLYMASTER:/155256h3720.48N/00558.27W^234/086/A=000032 !W81! 9 | FMT148694>OGFLYM,qAS,FLYMASTER:/155244h3720.58N/00558.11W^226/087/A=000019 !W81! 10 | FMT842374>OGFLYM,qAS,FLYMASTER:/155302h3720.44N/00558.34W^236/082/A=000013 !W88! 11 | FMT003725>OGFLYM,qAS,FLYMASTER:/155304h3652.58N/00255.91W^346/000/A=001968 !W66! 12 | FMT924469>OGFLYM,qAS,FLYMASTER:/155306h3720.42N/00558.40W^250/081/A=000013 !W85! 13 | FMT003549>OGFLYM,qAS,FLYMASTER:/155316h3751.64N/00126.08W^020/048/A=001322 !W98! 14 | FMT148694>OGFLYM,qAS,FLYMASTER:/155318h3720.42N/00558.59W^282/088/A=000026 !W85! 15 | FMT549112>OGFLYM,qAS,FLYMASTER:/155328h3720.45N/00558.75W^282/079/A=000032 !W60! 16 | FMT842374>OGFLYM,qAS,FLYMASTER:/155335h3720.47N/00558.84W^280/078/A=000019 !W68! 17 | FMT001300>OGFLYM,qAS,FLYMASTER:/155339h3706.99N/00807.27W^178/000/A=000131 !W95! 18 | FMT798890>OGFLYM,qAS,FLYMASTER:/155338h3720.48N/00558.89W^282/080/A=000019 !W46! 19 | FMT924469>OGFLYM,qAS,FLYMASTER:/155341h3720.49N/00558.93W^282/075/A=000009 !W27! 20 | FMT003725>OGFLYM,qAS,FLYMASTER:/155346h3652.58N/00255.91W^346/000/A=001971 !W75! 21 | FMT003549>OGFLYM,qAS,FLYMASTER:/155349h3751.76N/00125.91W^064/032/A=001414 !W27! 22 | FMT148694>OGFLYM,qAS,FLYMASTER:/155352h3720.51N/00559.02W^292/026/A=000026 !W48! 23 | FMT549112>OGFLYM,qAS,FLYMASTER:/155400h3720.52N/00559.06W^298/031/A=000045 !W74! 24 | FMT842374>OGFLYM,qAS,FLYMASTER:/155409h3720.54N/00559.10W^302/019/A=000042 !W70! 25 | FMT798890>OGFLYM,qAS,FLYMASTER:/155412h3720.54N/00559.10W^304/001/A=000026 !W96! 26 | FMT924469>OGFLYM,qAS,FLYMASTER:/155415h3720.54N/00559.10W^000/001/A=000022 !W95! 27 | FMT003725>OGFLYM,qAS,FLYMASTER:/155420h3652.58N/00255.91W^346/000/A=001971 !W75! 28 | FMT003549>OGFLYM,qAS,FLYMASTER:/155422h3751.81N/00125.73W^220/002/A=001584 !W42! 29 | FMT001300>OGFLYM,qAS,FLYMASTER:/155429h3706.99N/00807.27W^178/000/A=000131 !W96! 30 | FMT148694>OGFLYM,qAS,FLYMASTER:/155435h3720.58N/00559.16W^314/017/A=000039 !W83! 31 | FMT549112>OGFLYM,qAS,FLYMASTER:/155443h3720.59N/00559.16W^000/000/A=000065 !W18! 32 | FMT798890>OGFLYM,qAS,FLYMASTER:/155444h3720.59N/00559.16W^000/000/A=000039 !W29! 33 | FMT924469>OGFLYM,qAS,FLYMASTER:/155447h3720.59N/00559.16W^000/000/A=000039 !W28! 34 | FMT842374>OGFLYM,qAS,FLYMASTER:/155453h3720.60N/00559.17W^316/020/A=000055 !W07! 35 | FMT003549>OGFLYM,qAS,FLYMASTER:/155455h3751.82N/00125.81W^248/012/A=001676 !W99! -------------------------------------------------------------------------------- /app/templates/receiver_ranking.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 |
7 |

Receiver Ranking

8 |
9 | 10 |
11 |
12 | 18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | {% for (receiver, ranking, current, today, yesterday) in ranking %} 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {% endfor %} 49 | 50 |
RankTodayNameAirportDistance [km]SendersCoveragesMessages
{{ today }}{% if yesterday is none %}(new){% elif yesterday - today > 0 %}{{ yesterday - today }}{% elif yesterday - today < 0 %}{{ today - yesterday }}{% endif %}{% if current is not none %}{{ current }}{% else %}-{% endif %}{{ receiver|to_html_flag|safe }}{{ receiver|to_html_link|safe }}{{ receiver.airport|to_html_link|safe }}{% if ranking is not none %}{{ '%0.1f' | format(ranking.max_distance/1000.0) }}{% else %}-{% endif %}{% if ranking is not none %}{{ ranking.senders_count }}{% else %}-{% endif %}{% if ranking is not none %}{{ ranking.coverages_count }}{% else %}-{% endif %}{% if ranking is not none %}{{ ranking.messages_count }}{% else %}-{% endif %}
51 |
52 |
53 | 54 | {% endblock %} 55 | 56 | {% block scripts %} 57 | {{ super() }} 58 | 68 | {% endblock %} 69 | -------------------------------------------------------------------------------- /app/model/receiver.py: -------------------------------------------------------------------------------- 1 | from geoalchemy2.shape import to_shape 2 | from geoalchemy2.types import Geometry 3 | 4 | from .geo import Location 5 | 6 | from app import db 7 | 8 | from .airport import Airport 9 | from .receiver_state import ReceiverState 10 | 11 | 12 | class Receiver(db.Model): 13 | __tablename__ = "receivers" 14 | 15 | id = db.Column(db.Integer, primary_key=True) 16 | name = db.Column(db.String(9)) 17 | 18 | location_wkt = db.Column("location", Geometry("POINT", srid=4326)) 19 | altitude = db.Column(db.Float(precision=2)) 20 | 21 | firstseen = db.Column(db.DateTime, index=True) 22 | lastseen = db.Column(db.DateTime, index=True) 23 | timestamp = db.Column(db.DateTime, index=True) 24 | version = db.Column(db.String) 25 | platform = db.Column(db.String) 26 | cpu_temp = db.Column(db.Float(precision=2)) 27 | rec_input_noise = db.Column(db.Float(precision=2)) 28 | 29 | agl = db.Column(db.Float(precision=2)) 30 | 31 | # Relations 32 | country_id = db.Column(db.Integer, db.ForeignKey("countries.gid", ondelete="SET NULL"), index=True) 33 | country = db.relationship("Country", foreign_keys=[country_id], backref=db.backref("receivers", order_by="Receiver.name.asc()")) 34 | 35 | airport_id = db.Column(db.Integer, db.ForeignKey("airports.id", ondelete="CASCADE"), index=True) 36 | airport = db.relationship("Airport", foreign_keys=[airport_id], backref=db.backref("receivers", order_by="Receiver.name.asc()")) 37 | 38 | __table_args__ = (db.Index('idx_receivers_name_uc', 'name', unique=True), ) 39 | 40 | @property 41 | def location(self): 42 | if self.location_wkt is None: 43 | return None 44 | 45 | coords = to_shape(self.location_wkt) 46 | return Location(lat=coords.y, lon=coords.x) 47 | 48 | @property 49 | def state(self): 50 | import datetime 51 | if datetime.datetime.utcnow() - self.lastseen < datetime.timedelta(minutes=10): 52 | return ReceiverState.OK if len(self.statistics) > 0 else ReceiverState.ZOMBIE 53 | elif datetime.datetime.utcnow() - self.lastseen < datetime.timedelta(hours=1): 54 | return ReceiverState.UNKNOWN 55 | else: 56 | return ReceiverState.OFFLINE 57 | 58 | def airports_nearby(self): 59 | query = ( 60 | db.session.query(Airport, db.func.st_distance_sphere(self.location_wkt, Airport.location_wkt), db.func.st_azimuth(self.location_wkt, Airport.location_wkt)) 61 | .filter(db.func.st_contains(db.func.st_buffer(Airport.location_wkt, 1), self.location_wkt)) 62 | .filter(Airport.style.in_((2, 4, 5))) 63 | .order_by(db.func.st_distance_sphere(self.location_wkt, Airport.location_wkt).asc()) 64 | .limit(5) 65 | ) 66 | airports = [(airport, distance, azimuth) for airport, distance, azimuth in query] 67 | return airports 68 | -------------------------------------------------------------------------------- /app/templates/ogn_live.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Spot the gliders! 20 | 21 | 22 | 23 | 24 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 |
57 |
58 | 59 |
60 |
61 |
62 |
63 |
64 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /migrations/versions/7f5b8f65a977_add_receiverranking.py: -------------------------------------------------------------------------------- 1 | """Add ReceiverRanking 2 | 3 | Revision ID: 7f5b8f65a977 4 | Revises: c53fdb39f5a5 5 | Create Date: 2020-12-02 22:33:58.821112 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '7f5b8f65a977' 14 | down_revision = 'c53fdb39f5a5' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('receiver_rankings', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('date', sa.Date(), nullable=True), 24 | sa.Column('local_rank', sa.Integer(), nullable=True), 25 | sa.Column('global_rank', sa.Integer(), nullable=True), 26 | sa.Column('max_distance', sa.Float(precision=2), nullable=True), 27 | sa.Column('max_normalized_quality', sa.Float(precision=2), nullable=True), 28 | sa.Column('messages_count', sa.Integer(), nullable=True), 29 | sa.Column('coverages_count', sa.Integer(), nullable=True), 30 | sa.Column('senders_count', sa.Integer(), nullable=True), 31 | sa.Column('receiver_id', sa.Integer(), nullable=True), 32 | sa.Column('country_id', sa.Integer(), nullable=True), 33 | sa.ForeignKeyConstraint(['country_id'], ['countries.gid'], ondelete='CASCADE'), 34 | sa.ForeignKeyConstraint(['receiver_id'], ['receivers.id'], ondelete='CASCADE'), 35 | sa.PrimaryKeyConstraint('id') 36 | ) 37 | op.create_index('idx_receiver_rankings_uc', 'receiver_rankings', ['date', 'receiver_id'], unique=True) 38 | op.create_index(op.f('ix_receiver_rankings_country_id'), 'receiver_rankings', ['country_id'], unique=False) 39 | op.create_index(op.f('ix_receiver_rankings_receiver_id'), 'receiver_rankings', ['receiver_id'], unique=False) 40 | 41 | op.drop_column('receiver_statuses', 'agl') 42 | op.drop_column('receiver_statuses', 'location_mgrs') 43 | op.drop_column('receiver_statuses', 'location_mgrs_short') 44 | # ### end Alembic commands ### 45 | 46 | 47 | def downgrade(): 48 | # ### commands auto generated by Alembic - please adjust! ### 49 | op.add_column('receiver_statuses', sa.Column('location_mgrs_short', sa.VARCHAR(length=9), autoincrement=False, nullable=True)) 50 | op.add_column('receiver_statuses', sa.Column('location_mgrs', sa.VARCHAR(length=15), autoincrement=False, nullable=True)) 51 | op.add_column('receiver_statuses', sa.Column('agl', sa.REAL(), autoincrement=False, nullable=True)) 52 | 53 | op.drop_index(op.f('ix_receiver_rankings_receiver_id'), table_name='receiver_rankings') 54 | op.drop_index(op.f('ix_receiver_rankings_country_id'), table_name='receiver_rankings') 55 | op.drop_index('idx_receiver_rankings_uc', table_name='receiver_rankings') 56 | op.drop_table('receiver_rankings') 57 | # ### end Alembic commands ### 58 | -------------------------------------------------------------------------------- /migrations/versions/f3afd6197391_replaced_rank_with_pareto.py: -------------------------------------------------------------------------------- 1 | """Replaced 'rank' with 'pareto' 2 | 3 | Revision ID: f3afd6197391 4 | Revises: 310027ddeea9 5 | Create Date: 2020-12-08 08:41:49.170716 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'f3afd6197391' 14 | down_revision = '310027ddeea9' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('receiver_rankings', sa.Column('global_distance_pareto', sa.Float(precision=2), nullable=True)) 22 | op.add_column('receiver_rankings', sa.Column('local_distance_pareto', sa.Float(precision=2), nullable=True)) 23 | op.drop_column('receiver_rankings', 'global_rank') 24 | op.drop_column('receiver_rankings', 'longtime_local_rank_delta') 25 | op.drop_column('receiver_rankings', 'longtime_global_rank_delta') 26 | op.drop_column('receiver_rankings', 'longtime_global_rank') 27 | op.drop_column('receiver_rankings', 'longtime_local_rank') 28 | op.drop_column('receiver_rankings', 'local_rank') 29 | # ### end Alembic commands ### 30 | 31 | op.execute(""" 32 | UPDATE receiver_rankings AS rr 33 | SET 34 | local_distance_pareto = sq.local_distance_pareto, 35 | global_distance_pareto = sq.global_distance_pareto 36 | FROM 37 | ( 38 | SELECT 39 | date, 40 | receiver_id, 41 | 1.0 * RANK() OVER (PARTITION BY date, country_id ORDER BY max_distance) / COUNT(*) OVER (PARTITION BY date, country_id) AS local_distance_pareto, 42 | 1.0 * RANK() OVER (PARTITION BY date ORDER BY max_distance) / COUNT(*) OVER (PARTITION BY date) AS global_distance_pareto 43 | FROM receiver_rankings 44 | ) AS sq 45 | WHERE rr.date = sq.date AND rr.receiver_id = sq.receiver_id; 46 | """) 47 | 48 | 49 | def downgrade(): 50 | # ### commands auto generated by Alembic - please adjust! ### 51 | op.add_column('receiver_rankings', sa.Column('local_rank', sa.Float(precision=2), autoincrement=False, nullable=True)) 52 | op.add_column('receiver_rankings', sa.Column('longtime_local_rank', sa.Float(precision=2), autoincrement=False, nullable=True)) 53 | op.add_column('receiver_rankings', sa.Column('longtime_global_rank', sa.Float(precision=2), autoincrement=False, nullable=True)) 54 | op.add_column('receiver_rankings', sa.Column('longtime_global_rank_delta', sa.Float(precision=2), autoincrement=False, nullable=True)) 55 | op.add_column('receiver_rankings', sa.Column('longtime_local_rank_delta', sa.Float(precision=2), autoincrement=False, nullable=True)) 56 | op.add_column('receiver_rankings', sa.Column('global_rank', sa.Float(precision=2), autoincrement=False, nullable=True)) 57 | op.drop_column('receiver_rankings', 'local_distance_pareto') 58 | op.drop_column('receiver_rankings', 'global_distance_pareto') 59 | # ### end Alembic commands ### 60 | -------------------------------------------------------------------------------- /app/main/bokeh_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import current_app 3 | 4 | from bokeh.plotting import figure 5 | from bokeh.models import ColumnDataSource, HoverTool, WheelZoomTool, PanTool, ResetTool 6 | from bokeh.resources import CDN 7 | from bokeh.embed import file_html 8 | 9 | import pandas as pd 10 | import numpy as np 11 | 12 | COMMON_FREQUENCIES = [ 13 | [84.015, 87.2250, 'BOS 4m'], 14 | [87.500, 108.0000, 'VHF FM Radio'], 15 | [108.000, 111.9750, 'ILS'], 16 | [112.000, 117.9750, 'VOR'], 17 | [117.975, 137.0000, 'Aeronautical Radio'], 18 | [143.000, 146.0000, 'Amateur Radio 2m'], 19 | [165.210, 173.9800, 'BOS 2m'], 20 | [177.500, 226.5000, 'DVB-T VHF'], 21 | [273.000, 312.0000, 'Military'], 22 | [390.000, 399.9000, 'BOS Digital'], 23 | [430.000, 440.0000, 'Amateur Radio 70cm'], 24 | [448.600, 449.9625, 'BOS 70cm'], 25 | [474.000, 786.0000, 'DVB-T UHF'], 26 | [791.000, 821.0000, 'LTE downlink'], 27 | [832.000, 862.0000, 'LTE uplink'], 28 | [868.000, 868.6000, 'Flarm 868.3MHz'], 29 | [880.000, 915.0000, 'GSM 900 uplink'], 30 | [925.000, 960.0000, 'GSM 900 downlink'], 31 | [1025.000, 1095.0000, 'Aeronautical Navigation'], 32 | [1164.000, 1215.0000, 'Aeronautical Navigation (DME,TACAN)'], 33 | [1429.000, 1452.0000, 'Military'], 34 | ] 35 | 36 | 37 | def get_bokeh_frequency_scan(frequency_scan_file): 38 | # Read the frequency scan file 39 | df_scan = pd.read_csv(os.path.join(current_app.config['UPLOAD_PATH'], frequency_scan_file.name), header=None) 40 | df_scan.columns = ['date', 'time', 'hz_low', 'hz_high', 'hz_step', 'samples'] + [f"signal{c:02}" for c in range(1, len(df_scan.columns) - 5)] 41 | 42 | xval = df_scan['hz_low'] / 1000000 43 | yval = df_scan['signal01'] 44 | 45 | # Read the common frequences 46 | df_freq = pd.DataFrame(COMMON_FREQUENCIES, columns=['hz_low', 'hz_high', 'description'], dtype=float) 47 | 48 | N = len(df_freq.index) 49 | low = df_freq['hz_low'] 50 | high = df_freq['hz_high'] 51 | 52 | x = high - (high - low) / 2.0 53 | y = 0 * np.ones(N) 54 | width = high - low 55 | height = 50 * np.ones(N) 56 | desc = df_freq['description'] 57 | 58 | frequency_source = ColumnDataSource(data=dict(low=low, high=high, x=x, y=y, width=width, height=height, desc=desc)) 59 | 60 | # Create the figure with tool tips 61 | fig = figure(title=f"Frequency spectrum @ {frequency_scan_file.receiver.name}", sizing_mode='stretch_both', tools=[PanTool(), WheelZoomTool(), ResetTool()]) 62 | r1 = fig.rect(x='x', y='y', width='width', height='height', color="lightgrey", source=frequency_source, legend="Common Frequencies") 63 | r2 = fig.line(xval, yval, legend=f"Measurement (gain={frequency_scan_file.gain})") 64 | r3 = fig.line(x=[868.3, 868.3], y=[-25, 25], color="red", legend="Flarm") 65 | 66 | fig.add_tools(HoverTool(renderers=[r1], tooltips={"info": "@desc @low-@high MHz"})) 67 | fig.add_tools(HoverTool(renderers=[r2], tooltips={"f [MHz]": "$x", "P [dB]": "$y"})) 68 | 69 | fig.xaxis.axis_label = "Frequency [MHz]" 70 | fig.yaxis.axis_label = "Signal Power [dB]" 71 | fig.legend.click_policy = 'hide' 72 | 73 | return file_html(fig, CDN) 74 | -------------------------------------------------------------------------------- /app/main/jinja_filters.py: -------------------------------------------------------------------------------- 1 | from app.main import bp 2 | from app.model import Airport, Country, Sender, Receiver 3 | 4 | from flask import url_for 5 | import datetime 6 | import math 7 | 8 | 9 | @bp.app_template_filter() 10 | def to_html_flag(obj): 11 | if obj is None: 12 | return "" 13 | 14 | if isinstance(obj, str): 15 | return f"""{obj} """ 16 | 17 | elif isinstance(obj, Airport): 18 | return f"""{obj.country_code} """ 19 | 20 | elif isinstance(obj, Country): 21 | return f"""{obj.iso2} """ 22 | 23 | elif isinstance(obj, Sender): 24 | if obj is not None and len(obj.infos) > 0 and obj.infos[0].country is not None: 25 | return f"""{obj.infos[0].country.iso2} """ 26 | else: 27 | return "" 28 | 29 | elif isinstance(obj, Receiver): 30 | if obj.country: 31 | return f"""{obj.country.iso2} """ 32 | else: 33 | return "" 34 | 35 | 36 | @bp.app_template_filter() 37 | def to_html_link(obj): 38 | if isinstance(obj, Airport): 39 | airport = obj 40 | return f"""{airport.name}""" 41 | 42 | elif isinstance(obj, Sender): 43 | sender = obj 44 | if len(sender.infos) > 0 and len(sender.infos[0].registration) > 0: 45 | return f"""{sender.infos[0].registration}""" 46 | elif sender.address: 47 | return f"""[{sender.address}]""" 48 | else: 49 | return f"""[{sender.name}]""" 50 | 51 | elif isinstance(obj, Receiver): 52 | receiver = obj 53 | return f"""{receiver.name}""" 54 | 55 | elif obj is None: 56 | return "-" 57 | 58 | else: 59 | raise NotImplementedError("cant apply filter 'to_html_link' to object {type(obj)}") 60 | 61 | 62 | @bp.app_template_filter() 63 | def to_ordinal(rad): 64 | deg = math.degrees(rad) 65 | if deg >= 337.5 or deg < 22.5: 66 | return "N" 67 | elif deg >= 22.5 and deg < 67.5: 68 | return "NW" 69 | elif deg >= 67.5 and deg < 112.5: 70 | return "W" 71 | elif deg >= 112.5 and deg < 157.5: 72 | return "SW" 73 | elif deg >= 157.5 and deg < 202.5: 74 | return "S" 75 | elif deg >= 202.5 and deg < 247.5: 76 | return "SE" 77 | elif deg >= 247.5 and deg < 292.5: 78 | return "E" 79 | elif deg >= 292.5 and deg < 337.5: 80 | return "NE" 81 | -------------------------------------------------------------------------------- /app/templates/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'bootstrap/base.html' %} 2 | 3 | {% block styles %} 4 | {{ super() }} 5 | 6 | 7 | 8 | {% endblock %} 9 | 10 | {% block title %} 11 | {% if title %}{{ title }}{% else %}No page title{% endif %} 12 | {% endblock %} 13 | 14 | {% block navbar %} 15 | 16 | 40 | {% endblock %} 41 | 42 | {% block content %} 43 |
44 | {% with messages = get_flashed_messages(with_categories=true) %} 45 | {% if messages %} 46 | 47 | {% for category, message in messages %} 48 | {% if category == 'message' %} 49 | 64 | {% endblock %} 65 | 66 | {% block scripts %} 67 | {{ super() }} 68 | 69 | 70 | {% endblock %} 71 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | logger = logging.getLogger('alembic.env') 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | from flask import current_app 21 | config.set_main_option('sqlalchemy.url', 22 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 23 | target_metadata = current_app.extensions['migrate'].db.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline(): 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | url = config.get_main_option("sqlalchemy.url") 44 | context.configure(url=url) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | 58 | # this callback is used to prevent an auto-migration from being generated 59 | # when there are no changes to the schema 60 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 61 | def process_revision_directives(context, revision, directives): 62 | if getattr(config.cmd_opts, 'autogenerate', False): 63 | script = directives[0] 64 | if script.upgrade_ops.is_empty(): 65 | directives[:] = [] 66 | logger.info('No changes in schema detected.') 67 | 68 | engine = engine_from_config(config.get_section(config.config_ini_section), 69 | prefix='sqlalchemy.', 70 | poolclass=pool.NullPool) 71 | 72 | connection = engine.connect() 73 | context.configure(connection=connection, 74 | target_metadata=target_metadata, 75 | process_revision_directives=process_revision_directives, 76 | **current_app.extensions['migrate'].configure_args) 77 | 78 | try: 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | except Exception as exception: 82 | logger.error(exception) 83 | raise exception 84 | finally: 85 | connection.close() 86 | 87 | if context.is_offline_mode(): 88 | run_migrations_offline() 89 | else: 90 | run_migrations_online() 91 | -------------------------------------------------------------------------------- /deployment/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | db: 4 | restart: always 5 | image: timescale/timescaledb-postgis:latest-pg11 6 | networks: 7 | - ogn 8 | volumes: 9 | - ./postgres-data:/var/lib/postgresql/data 10 | environment: 11 | POSTGRES_PASSWORD: postgres 12 | POSTGRES_DB: ogn 13 | ports: 14 | - "127.0.0.1:5432:5432" 15 | 16 | redis: 17 | restart: always 18 | image: redis 19 | networks: 20 | - ogn 21 | ports: 22 | - "127.0.0.1:6379:6379" 23 | 24 | ogn-pg-importer: 25 | image: ogn-pg-importer 26 | networks: 27 | - ogn 28 | depends_on: 29 | - db 30 | - backend 31 | volumes: 32 | - ./data:/data 33 | environment: 34 | PGHOST: db 35 | PGDATABASE: ogn 36 | PGPASSWORD: postgres 37 | PGUSER: postgres 38 | BACKENDHOST: backend 39 | BACKENDPORT: 80 40 | 41 | backend: 42 | restart: always 43 | image: ogn:latest 44 | networks: 45 | - ogn 46 | depends_on: 47 | - db 48 | - redis 49 | ports: 50 | - "0.0.0.0:8080:80" 51 | volumes: 52 | - ./cups:/cups 53 | environment: 54 | PGHOST: db 55 | SQLALCHEMY_DATABASE_URI: "postgresql://postgres:postgres@db:5432/ogn" 56 | CELERY_BROKER_URL: "redis://redis:6379/0" 57 | CELERY_RESULT_BACKEND: "redis://redis:6379/0" 58 | MODULE_NAME: "app" 59 | 60 | gateway: 61 | restart: always 62 | image: ogn:latest 63 | networks: 64 | - ogn 65 | depends_on: 66 | - db 67 | - redis 68 | - backend 69 | command: "./wait.sh flask gateway run" 70 | environment: 71 | SQLALCHEMY_DATABASE_URI: "postgresql://postgres:postgres@db:5432/ogn" 72 | CELERY_BROKER_URL: "redis://redis:6379/0" 73 | CELERY_RESULT_BACKEND: "redis://redis:6379/0" 74 | BACKENDHOST: backend 75 | BACKENDPORT: 80 76 | worker: 77 | restart: always 78 | image: ogn:latest 79 | networks: 80 | - ogn 81 | depends_on: 82 | - db 83 | - redis 84 | - backend 85 | command: "./wait.sh celery -A app.collect worker -l info" 86 | environment: 87 | SQLALCHEMY_DATABASE_URI: "postgresql://postgres:postgres@db:5432/ogn" 88 | CELERY_BROKER_URL: "redis://redis:6379/0" 89 | CELERY_RESULT_BACKEND: "redis://redis:6379/0" 90 | BACKENDHOST: backend 91 | BACKENDPORT: 80 92 | beat: 93 | restart: always 94 | image: ogn:latest 95 | networks: 96 | - ogn 97 | depends_on: 98 | - db 99 | - redis 100 | - backend 101 | command: "./wait.sh celery -A app.collect beat -l info" 102 | environment: 103 | SQLALCHEMY_DATABASE_URI: "postgresql://postgres:postgres@db:5432/ogn" 104 | CELERY_BROKER_URL: "redis://redis:6379/0" 105 | CELERY_RESULT_BACKEND: "redis://redis:6379/0" 106 | BACKENDHOST: backend 107 | BACKENDPORT: 80 108 | flower: 109 | restart: always 110 | image: mher/flower 111 | networks: 112 | - ogn 113 | depends_on: 114 | - redis 115 | ports: 116 | - "0.0.0.0:5555:5555" 117 | command: "flower --port=5555 -l info --broker=redis://redis:6379/0" 118 | 119 | networks: 120 | ogn: 121 | driver: bridge 122 | -------------------------------------------------------------------------------- /app/static/ognlive/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns an XMLHttp instance to use for asynchronous 3 | * downloading. This method will never throw an exception, but will 4 | * return NULL if the browser does not support XmlHttp for any reason. 5 | * @return {XMLHttpRequest|Null} 6 | */ 7 | function createXmlHttpRequest() { 8 | try { 9 | if (typeof ActiveXObject != 'undefined') { 10 | return new ActiveXObject('Microsoft.XMLHTTP'); 11 | } else if (window["XMLHttpRequest"]) { 12 | return new XMLHttpRequest(); 13 | } 14 | } catch (e) { 15 | changeStatus(e); 16 | } 17 | return null; 18 | }; 19 | 20 | /** 21 | * This functions wraps XMLHttpRequest open/send function. 22 | * It lets you specify a URL and will call the callback if 23 | * it gets a status code of 200. 24 | * @param {String} url The URL to retrieve 25 | * @param {Function} callback The function to call once retrieved. 26 | */ 27 | function downloadUrl(url, callback) { 28 | var status = -1; 29 | var request = createXmlHttpRequest(); 30 | if (!request) { 31 | return false; 32 | } 33 | 34 | request.onreadystatechange = function() { 35 | if (request.readyState == 4) { 36 | try { 37 | status = request.status; 38 | } catch (e) { 39 | // Usually indicates request timed out in FF. 40 | } 41 | if (status == 200) { 42 | callback(request.responseXML, request.status); 43 | request.onreadystatechange = function() {}; 44 | } 45 | } 46 | } 47 | request.open('GET', url, true); 48 | try { 49 | request.send(null); 50 | } catch (e) { 51 | changeStatus(e); 52 | } 53 | }; 54 | 55 | function downloadUrltxt(url, callback) { 56 | var status = -1; 57 | var request = createXmlHttpRequest(); 58 | if (!request) { 59 | return false; 60 | } 61 | 62 | request.onreadystatechange = function() { 63 | if (request.readyState == 4) { 64 | try { 65 | status = request.status; 66 | } catch (e) { 67 | // Usually indicates request timed out in FF. 68 | } 69 | if (status == 200) { 70 | callback(request.responseText, request.status); 71 | request.onreadystatechange = function() {}; 72 | } 73 | } 74 | } 75 | request.open('GET', url, true); 76 | try { 77 | request.send(null); 78 | } catch (e) { 79 | changeStatus(e); 80 | } 81 | }; 82 | 83 | 84 | /** 85 | * Parses the given XML string and returns the parsed document in a 86 | * DOM data structure. This function will return an empty DOM node if 87 | * XML parsing is not supported in this browser. 88 | * @param {string} str XML string. 89 | * @return {Element|Document} DOM. 90 | */ 91 | function xmlParse(str) { 92 | if (typeof ActiveXObject != 'undefined' && typeof GetObject != 'undefined') { 93 | var doc = new ActiveXObject('Microsoft.XMLDOM'); 94 | doc.loadXML(str); 95 | return doc; 96 | } 97 | 98 | if (typeof DOMParser != 'undefined') { 99 | return (new DOMParser()).parseFromString(str, 'text/xml'); 100 | } 101 | 102 | return createElement('div', null); 103 | } 104 | 105 | /** 106 | * Appends a JavaScript file to the page. 107 | * @param {string} url 108 | */ 109 | function downloadScript(url) { 110 | var script = document.createElement('script'); 111 | script.src = url; 112 | document.body.appendChild(script); 113 | } -------------------------------------------------------------------------------- /app/static/files/style.css: -------------------------------------------------------------------------------- 1 | 2 | #map_canvas { 3 | width: 100%; 4 | height: 100%; 5 | margin: 0px; 6 | padding: 0px; 7 | } 8 | i.subtle { 9 | color: #888; 10 | } 11 | 12 | .dropdown-submenu{position:relative;} 13 | .dropdown-submenu>.dropdown-menu{top:0;left:100%;margin-top:-6px;margin-left:-1px;-webkit-border-radius:0 6px 6px 6px;-moz-border-radius:0 6px 6px 6px;border-radius:0 6px 6px 6px;} 14 | .dropdown-submenu:hover>.dropdown-menu{display:block;} 15 | .dropdown-submenu>a:after{display:block;content:" ";float:right;width:0;height:0;border-color:transparent;border-style:solid;border-width:5px 0 5px 5px;border-left-color:#cccccc;margin-top:5px;margin-right:-10px;} 16 | .dropdown-submenu:hover>a:after{border-left-color:#ffffff;} 17 | .dropdown-submenu.pull-left{float:none;}.dropdown-submenu.pull-left>.dropdown-menu{left:-200%;right:+100%;margin-left:10px;-webkit-border-radius:6px 0 6px 6px;-moz-border-radius:6px 0 6px 6px;border-radius:6px 0 6px 6px;} 18 | 19 | .tt-dropdown-menu { 20 | position: absolute; 21 | top: 100%; 22 | left: 0; 23 | z-index: 1000; 24 | display: none; 25 | float: left; 26 | min-width: 160px; 27 | padding: 5px 0; 28 | margin: 2px 0 0; 29 | list-style: none; 30 | font-size: 14px; 31 | background-color: #ffffff; 32 | border: 1px solid #cccccc; 33 | border: 1px solid rgba(0, 0, 0, 0.15); 34 | border-radius: 4px; 35 | -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); 36 | box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); 37 | background-clip: padding-box; 38 | } 39 | .tt-suggestion > p { 40 | display: block; 41 | padding: 3px 20px; 42 | clear: both; 43 | font-weight: normal; 44 | line-height: 1.428571429; 45 | color: #333333; 46 | white-space: nowrap; 47 | } 48 | .tt-suggestion > p:hover, 49 | .tt-suggestion > p:focus, 50 | .tt-suggestion.tt-cursor p { 51 | color: #ffffff; 52 | text-decoration: none; 53 | outline: 0; 54 | background-color: #428bca; 55 | } 56 | .tooltip { 57 | position: relative; 58 | padding: 3px; 59 | background: rgba(0, 0, 0, 0.8); 60 | color: white; 61 | opacity: 0.7; 62 | white-space: nowrap; 63 | font: 10pt sans-serif; 64 | } 65 | 66 | .ol-zoom { 67 | left: 50%; 68 | top: 0px; 69 | } 70 | 71 | .ol-control button { 72 | float: left 73 | } 74 | 75 | .ol-rotate { 76 | visibility: hidden; 77 | } 78 | 79 | .l-outer-container { 80 | position: absolute; 81 | top: 0; 82 | bottom: 0; 83 | left: 0; 84 | right: 0; 85 | } 86 | 87 | .l-table { 88 | display: table; 89 | } 90 | 91 | .l-table-row { 92 | display: table-row; 93 | } 94 | 95 | .l-table-cell { 96 | display: table-cell; 97 | } 98 | 99 | .l-container { 100 | width: 100%; 101 | height: 100%; 102 | padding-left : 0px; 103 | padding-right : 0px; 104 | } 105 | 106 | .l-body-content-outer-wrapper { 107 | height: 100%; 108 | } 109 | 110 | .l-body-content-inner-wrapper { 111 | height: 100%; 112 | position: relative; 113 | overflow: auto; 114 | } 115 | 116 | .l-body-content { 117 | position: absolute; 118 | top: 0; 119 | bottom: 0; 120 | left: 0; 121 | right: 0; 122 | } 123 | 124 | .l-smaller { 125 | margin-right: 0px; 126 | margin-bottom: 0px; 127 | margin-top: 0px; 128 | padding: 5px 5px"; 129 | } -------------------------------------------------------------------------------- /app/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 | 7 | 8 |
9 |

Today

10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
SendersReceiversTakeoffsLandingsSender PositionsSender Positions Total
{{ senders_today }}{{ receivers_today }}{{ takeoffs_today }}{{ landings_today }}{{ sender_positions_today }}{{ sender_positions_total }}
32 |
33 | 34 | 35 |
36 |

Logbook

37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | {% set ns = namespace(mydate=none) %} 63 | {% for entry in logbook %} 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | {% endfor %} 77 | 78 |
AircraftAirportTime UTC
#DateRegistrationTypeTakeoffLandingTakeoffLandingDurationAGL
{{ loop.index }}{% if ns.mydate != entry.reference_timestamp.strftime('%Y-%m-%d') %}{% set ns.mydate = entry.reference_timestamp.strftime('%Y-%m-%d') %}{{ ns.mydate }}{% endif %}{{ entry.sender|to_html_flag|safe }}{{ entry.sender|to_html_link|safe }}{% if entry.sender.infos|length > 0 and entry.sender.infos[0].aircraft|length %}{{ entry.sender.infos[0].aircraft }}{% else %}-{% endif %}{% if entry.takeoff_airport is not none %}{{ entry.takeoff_airport.name }}{% endif %}{% if entry.landing_airport is not none %}{{ entry.landing_airport.name }}{% endif %}{% if entry.takeoff_timestamp is not none %} {{ entry.takeoff_timestamp.strftime('%H:%M') }} {% endif %}{% if entry.landing_timestamp is not none %} {{ entry.landing_timestamp.strftime('%H:%M') }} {% endif %}{% if entry.duration is not none %}{{ entry.duration }}{% endif %}{% if entry.max_altitude is not none %}{{ '%0.1f'|format(entry.max_altitude - entry.takeoff_airport.altitude) }} m{% endif %}
79 |
80 |
81 | 82 | {% endblock %} 83 | 84 | 85 | -------------------------------------------------------------------------------- /tests/gateway/beacon_data/valid_beacon_data/aprs_receiver.txt: -------------------------------------------------------------------------------- 1 | # Until OGN software 0.2.6 all beacons (flarms, ogn trackers and receivers) have the dstcall "APRS" 2 | # These are example beacons for receivers 3 | # 4 | Lachens>APRS,TCPIP*,qAC,GLIDERN2:/165334h4344.70NI00639.19E&/A=005435 v0.2.1 CPU:0.3 RAM:1764.4/2121.4MB NTP:2.8ms/+4.9ppm +47.0C RF:+0.70dB 5 | LFGU>APRS,TCPIP*,qAC,GLIDERN2:/165556h4907.63NI00706.41E&/A=000833 v0.2.0 CPU:0.9 RAM:281.3/458.9MB NTP:0.5ms/-19.1ppm +53.0C RF:+0.70dB 6 | LSGS>APRS,TCPIP*,qAC,GLIDERN1:/165345h4613.25NI00719.68E&/A=001581 CPU:0.7 RAM:247.9/456.4MB NTP:0.7ms/-11.4ppm +44.4C RF:+53+71.9ppm/+0.4dB 7 | WolvesSW>APRS,TCPIP*,qAC,GLIDERN2:/165343h5232.23NI00210.91W&/A=000377 CPU:1.5 RAM:159.9/458.7MB NTP:6.6ms/-36.7ppm +45.5C RF:+130-0.4ppm/-0.1dB 8 | Oxford>APRS,TCPIP*,qAC,GLIDERN1:/165533h5142.96NI00109.68W&/A=000380 v0.1.3 CPU:0.9 RAM:268.8/458.6MB NTP:0.5ms/-45.9ppm +60.5C RF:+55+2.9ppm/+1.54dB 9 | Salland>APRS,TCPIP*,qAC,GLIDERN2:/165426h5227.93NI00620.03E&/A=000049 v0.2.2 CPU:0.7 RAM:659.3/916.9MB NTP:2.5ms/-75.0ppm RF:+0.41dB 10 | LSGS>APRS,TCPIP*,qAC,GLIDERN1:/165345h4613.25NI00719.68E&/A=001581 CPU:0.7 RAM:247.9/456.4MB NTP:0.7ms/-11.4ppm +44.4C RF:+53+71.9ppm/+0.4dB 11 | Drenstein>APRS,TCPIP*,qAC,GLIDERN1:/165011h5147.51NI00744.45E&/A=000213 v0.2.2 CPU:0.8 RAM:695.7/4025.5MB NTP:16000.0ms/+0.0ppm +63.0C 12 | # 13 | # since 0.2.5 for receiver information not only the "aprs position" format is used but also the "aprs status" format (without lat/lon/alt informations) 14 | Cordoba>APRS,TCPIP*,qAC,GLIDERN3:/194847h3112.85SI06409.56W&/A=001712 v0.2.5.ARM CPU:0.4 RAM:755.4/970.8MB NTP:6.7ms/-0.1ppm +45.5C RF:+48+18.3ppm/+3.45dB 15 | Cordoba>APRS,TCPIP*,qAC,GLIDERN3:>194847h v0.2.5.ARM CPU:0.4 RAM:755.4/970.8MB NTP:6.7ms/-0.1ppm +45.5C 0/0Acfts[1h] RF:+48+18.3ppm/+3.45dB/+0.4dB@10km[71]/+0.4dB@10km[1/1] 16 | VITACURA1>APRS,TCPIP*,qAC,GLIDERN3:/042149h3322.81SI07034.95W&/A=002345 v0.2.5.ARM CPU:0.6 RAM:694.4/970.5MB NTP:0.8ms/-7.5ppm +54.8C RF:+0-0.2ppm/+3.81dB 17 | VITACURA1>APRS,TCPIP*,qAC,GLIDERN3:>042149h v0.2.5.ARM CPU:0.6 RAM:694.4/970.5MB NTP:0.8ms/-7.5ppm +54.8C 0/0Acfts[1h] RF:+0-0.2ppm/+3.81dB/+1.3dB@10km[132205]/+6.6dB@10km[10/20] 18 | Arnsberg>APRS,TCPIP*,qAC,GLIDERN1:/042146h5123.04NI00803.77E&/A=000623 v0.2.5.ARM CPU:0.4 RAM:765.1/970.8MB NTP:0.4ms/-1.7ppm +62.3C RF:+27+1.1ppm/+3.17dB 19 | Arnsberg>APRS,TCPIP*,qAC,GLIDERN1:>042146h v0.2.5.ARM CPU:0.4 RAM:764.9/970.8MB NTP:0.4ms/-1.7ppm +62.3C 0/0Acfts[1h] RF:+27+1.1ppm/+3.17dB/+9.2dB@10km[44487]/+12.1dB@10km[20/40] 20 | CNF3a>APRS,TCPIP*,qAC,GLIDERN3:/042143h4529.25NI07505.65W&/A=000259 v0.2.5.ARM CPU:0.6 RAM:514.6/970.8MB NTP:4.5ms/-1.5ppm +27.2C RF:+0-0.4ppm/+18.69dB 21 | CNF3a>APRS,TCPIP*,qAC,GLIDERN3:>042143h v0.2.5.ARM CPU:0.6 RAM:514.6/970.8MB NTP:4.5ms/-1.5ppm +27.2C 0/0Acfts[1h] RF:+0-0.4ppm/+18.69dB/+13.0dB@10km[104282]/+9.7dB@10km[2/3] 22 | VITACURA2>APRS,TCPIP*,qAC,GLIDERN3:/042136h3322.81SI07034.95W&/A=002345 v0.2.5.ARM CPU:0.3 RAM:695.0/970.5MB NTP:0.6ms/-5.7ppm +51.5C RF:+0-0.0ppm/+1.32dB 23 | VITACURA2>APRS,TCPIP*,qAC,GLIDERN3:>042136h v0.2.5.ARM CPU:0.3 RAM:695.0/970.5MB NTP:0.6ms/-5.7ppm +52.1C 0/0Acfts[1h] RF:+0-0.0ppm/+1.32dB/+2.1dB@10km[193897]/+9.0dB@10km[10/20] 24 | # 25 | # since 0.2.6 the ogn comment of a receiver beacon is just optional, it also can be a user comment 26 | Ulrichamn>APRS,TCPIP*,qAC,GLIDERN1:/085616h5747.30NI01324.77E&/A=001322 27 | ROBLE3>APRS,TCPIP*,qAC,GLIDERN4:/200022h3258.58SI07100.78W&/A=007229 Contact: achanes@manquehue.net, brito.felipe@gmail.com 28 | # 29 | # ... and user comment can include a 'id' 30 | ALFALFAL>APRS,TCPIP*,qAC,GLIDERN4:/221830h3330.40SI07007.88W&/A=008659 Alfalfal Hidroelectric Plant, Club de Planeadores Vitacurs -------------------------------------------------------------------------------- /app/templates/logbooks.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 |
7 |

Logbook

8 |
9 | 10 | 11 |
12 |
13 | 19 | 25 | 34 |
35 |
36 | 37 | 38 | {% if logbooks is not none %} 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | {% for entry in logbooks %} 51 | 52 | {% set sender = entry.sender %} 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 66 | 67 | {% endfor %} 68 |
#RegistrationTypeTakeoffLandingTimeAGLRemark
{{ loop.index }}{{ sender|to_html_link|safe }}{% if sender.infos|length > 0 and sender.infos[0].aircraft|length %}{{ sender.infos[0].aircraft }}{% else %}-{% endif %}{% if entry.takeoff_timestamp is not none and entry.takeoff_airport.id == sel_airport_id %} {{ entry.takeoff_timestamp.strftime('%H:%M') }} {% endif %}{% if entry.takeoff_track is not none and entry.takeoff_airport.id == sel_airport_id %} {{ '%02d' | format(entry.takeoff_track/10) }} {% endif %}{% if entry.landing_timestamp is not none and entry.landing_airport.id == sel_airport_id %} {{ entry.landing_timestamp.strftime('%H:%M') }} {% endif %}{% if entry.landing_track is not none and entry.landing_airport.id == sel_airport_id %} {{ '%02d' | format(entry.landing_track/10) }} {% endif %}{% if entry.duration is not none %}{{ entry.duration }}{% endif %}{% if entry.max_altitude is not none %}{{ '%d' | format(entry.max_altitude - entry.takeoff_airport.altitude) }} m{% endif %} 62 | {% if entry.takeoff_airport is not none and entry.takeoff_airport.id != sel_airport_id %}Take Off: {{ entry.takeoff_airport|to_html_flag|safe }}{{ entry.takeoff_airport.name }} 63 | {% elif entry.landing_airport is not none and entry.landing_airport.id != sel_airport_id %}Landing: {{ entry.landing_airport|to_html_flag|safe }}{{ entry.landing_airport.name }} 64 | {% endif %} 65 |
69 | {% endif %} 70 |
71 |
72 | 73 | {% endblock %} -------------------------------------------------------------------------------- /app/static/ognlive/ol.css: -------------------------------------------------------------------------------- 1 | .ol-box{box-sizing:border-box;border-radius:2px;border:2px solid #00f}.ol-mouse-position{top:8px;right:8px;position:absolute}.ol-scale-line{background:rgba(0,60,136,.3);border-radius:4px;bottom:8px;left:8px;padding:2px;position:absolute}.ol-scale-line-inner{border:1px solid #eee;border-top:none;color:#eee;font-size:10px;text-align:center;margin:1px;will-change:contents,width}.ol-overlay-container{will-change:left,right,top,bottom}.ol-unsupported{display:none}.ol-unselectable,.ol-viewport{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent}.ol-selectable{-webkit-touch-callout:default;-webkit-user-select:auto;-moz-user-select:auto;-ms-user-select:auto;user-select:auto}.ol-grabbing{cursor:-webkit-grabbing;cursor:-moz-grabbing;cursor:grabbing}.ol-grab{cursor:move;cursor:-webkit-grab;cursor:-moz-grab;cursor:grab}.ol-control{position:absolute;background-color:rgba(255,255,255,.4);border-radius:4px;padding:2px}.ol-control:hover{background-color:rgba(255,255,255,.6)}.ol-zoom{top:.5em;left:.5em}.ol-rotate{top:.5em;right:.5em;transition:opacity .25s linear,visibility 0s linear}.ol-rotate.ol-hidden{opacity:0;visibility:hidden;transition:opacity .25s linear,visibility 0s linear .25s}.ol-zoom-extent{top:4.643em;left:.5em}.ol-full-screen{right:.5em;top:.5em}@media print{.ol-control{display:none}}.ol-control button{display:block;margin:1px;padding:0;color:#fff;font-size:1.14em;font-weight:700;text-decoration:none;text-align:center;height:1.375em;width:1.375em;line-height:.4em;background-color:rgba(0,60,136,.5);border:none;border-radius:2px}.ol-control button::-moz-focus-inner{border:none;padding:0}.ol-zoom-extent button{line-height:1.4em}.ol-compass{display:block;font-weight:400;font-size:1.2em;will-change:transform}.ol-touch .ol-control button{font-size:1.5em}.ol-touch .ol-zoom-extent{top:5.5em}.ol-control button:focus,.ol-control button:hover{text-decoration:none;background-color:rgba(0,60,136,.7)}.ol-zoom .ol-zoom-in{border-radius:2px 2px 0 0}.ol-zoom .ol-zoom-out{border-radius:0 0 2px 2px}.ol-attribution{text-align:right;bottom:.5em;right:.5em;max-width:calc(100% - 1.3em)}.ol-attribution ul{margin:0;padding:0 .5em;font-size:.7rem;line-height:1.375em;color:#000;text-shadow:0 0 2px #fff}.ol-attribution li{display:inline;list-style:none;line-height:inherit}.ol-attribution li:not(:last-child):after{content:" "}.ol-attribution img{max-height:2em;max-width:inherit;vertical-align:middle}.ol-attribution button,.ol-attribution ul{display:inline-block}.ol-attribution.ol-collapsed ul{display:none}.ol-attribution.ol-logo-only ul{display:block}.ol-attribution:not(.ol-collapsed){background:rgba(255,255,255,.8)}.ol-attribution.ol-uncollapsible{bottom:0;right:0;border-radius:4px 0 0;height:1.1em;line-height:1em}.ol-attribution.ol-logo-only{background:0 0;bottom:.4em;height:1.1em;line-height:1em}.ol-attribution.ol-uncollapsible img{margin-top:-.2em;max-height:1.6em}.ol-attribution.ol-logo-only button,.ol-attribution.ol-uncollapsible button{display:none}.ol-zoomslider{top:4.5em;left:.5em;height:200px}.ol-zoomslider button{position:relative;height:10px}.ol-touch .ol-zoomslider{top:5.5em}.ol-overviewmap{left:.5em;bottom:.5em}.ol-overviewmap.ol-uncollapsible{bottom:0;left:0;border-radius:0 4px 0 0}.ol-overviewmap .ol-overviewmap-map,.ol-overviewmap button{display:inline-block}.ol-overviewmap .ol-overviewmap-map{border:1px solid #7b98bc;height:150px;margin:2px;width:150px}.ol-overviewmap:not(.ol-collapsed) button{bottom:1px;left:2px;position:absolute}.ol-overviewmap.ol-collapsed .ol-overviewmap-map,.ol-overviewmap.ol-uncollapsible button{display:none}.ol-overviewmap:not(.ol-collapsed){background:rgba(255,255,255,.8)}.ol-overviewmap-box{border:2px dotted rgba(0,60,136,.7)}.ol-overviewmap .ol-overviewmap-box:hover{cursor:move} -------------------------------------------------------------------------------- /tests/gateway/valid_messages/APRS_receiver.txt: -------------------------------------------------------------------------------- 1 | # The following beacons are example for the (deprecated) APRS format for ogn receivers 2 | # source: https://github.com/glidernet/ogn-aprs-protocol 3 | # 4 | # Until OGN software 0.2.6 all beacons (flarms, ogn trackers and receivers) have the dstcall "APRS" 5 | # These are example beacons for receivers 6 | # 7 | Lachens>APRS,TCPIP*,qAC,GLIDERN2:/165334h4344.70NI00639.19E&/A=005435 v0.2.1 CPU:0.3 RAM:1764.4/2121.4MB NTP:2.8ms/+4.9ppm +47.0C RF:+0.70dB 8 | LFGU>APRS,TCPIP*,qAC,GLIDERN2:/165556h4907.63NI00706.41E&/A=000833 v0.2.0 CPU:0.9 RAM:281.3/458.9MB NTP:0.5ms/-19.1ppm +53.0C RF:+0.70dB 9 | LSGS>APRS,TCPIP*,qAC,GLIDERN1:/165345h4613.25NI00719.68E&/A=001581 CPU:0.7 RAM:247.9/456.4MB NTP:0.7ms/-11.4ppm +44.4C RF:+53+71.9ppm/+0.4dB 10 | WolvesSW>APRS,TCPIP*,qAC,GLIDERN2:/165343h5232.23NI00210.91W&/A=000377 CPU:1.5 RAM:159.9/458.7MB NTP:6.6ms/-36.7ppm +45.5C RF:+130-0.4ppm/-0.1dB 11 | Oxford>APRS,TCPIP*,qAC,GLIDERN1:/165533h5142.96NI00109.68W&/A=000380 v0.1.3 CPU:0.9 RAM:268.8/458.6MB NTP:0.5ms/-45.9ppm +60.5C RF:+55+2.9ppm/+1.54dB 12 | Salland>APRS,TCPIP*,qAC,GLIDERN2:/165426h5227.93NI00620.03E&/A=000049 v0.2.2 CPU:0.7 RAM:659.3/916.9MB NTP:2.5ms/-75.0ppm RF:+0.41dB 13 | LSGS>APRS,TCPIP*,qAC,GLIDERN1:/165345h4613.25NI00719.68E&/A=001581 CPU:0.7 RAM:247.9/456.4MB NTP:0.7ms/-11.4ppm +44.4C RF:+53+71.9ppm/+0.4dB 14 | Drenstein>APRS,TCPIP*,qAC,GLIDERN1:/165011h5147.51NI00744.45E&/A=000213 v0.2.2 CPU:0.8 RAM:695.7/4025.5MB NTP:16000.0ms/+0.0ppm +63.0C 15 | # 16 | # since 0.2.5 for receiver information not only the "aprs position" format is used but also the "aprs status" format (without lat/lon/alt informations) 17 | Cordoba>APRS,TCPIP*,qAC,GLIDERN3:/194847h3112.85SI06409.56W&/A=001712 v0.2.5.ARM CPU:0.4 RAM:755.4/970.8MB NTP:6.7ms/-0.1ppm +45.5C RF:+48+18.3ppm/+3.45dB 18 | Cordoba>APRS,TCPIP*,qAC,GLIDERN3:>194847h v0.2.5.ARM CPU:0.4 RAM:755.4/970.8MB NTP:6.7ms/-0.1ppm +45.5C 0/0Acfts[1h] RF:+48+18.3ppm/+3.45dB/+0.4dB@10km[71]/+0.4dB@10km[1/1] 19 | VITACURA1>APRS,TCPIP*,qAC,GLIDERN3:/042149h3322.81SI07034.95W&/A=002345 v0.2.5.ARM CPU:0.6 RAM:694.4/970.5MB NTP:0.8ms/-7.5ppm +54.8C RF:+0-0.2ppm/+3.81dB 20 | VITACURA1>APRS,TCPIP*,qAC,GLIDERN3:>042149h v0.2.5.ARM CPU:0.6 RAM:694.4/970.5MB NTP:0.8ms/-7.5ppm +54.8C 0/0Acfts[1h] RF:+0-0.2ppm/+3.81dB/+1.3dB@10km[132205]/+6.6dB@10km[10/20] 21 | Arnsberg>APRS,TCPIP*,qAC,GLIDERN1:/042146h5123.04NI00803.77E&/A=000623 v0.2.5.ARM CPU:0.4 RAM:765.1/970.8MB NTP:0.4ms/-1.7ppm +62.3C RF:+27+1.1ppm/+3.17dB 22 | Arnsberg>APRS,TCPIP*,qAC,GLIDERN1:>042146h v0.2.5.ARM CPU:0.4 RAM:764.9/970.8MB NTP:0.4ms/-1.7ppm +62.3C 0/0Acfts[1h] RF:+27+1.1ppm/+3.17dB/+9.2dB@10km[44487]/+12.1dB@10km[20/40] 23 | CNF3a>APRS,TCPIP*,qAC,GLIDERN3:/042143h4529.25NI07505.65W&/A=000259 v0.2.5.ARM CPU:0.6 RAM:514.6/970.8MB NTP:4.5ms/-1.5ppm +27.2C RF:+0-0.4ppm/+18.69dB 24 | CNF3a>APRS,TCPIP*,qAC,GLIDERN3:>042143h v0.2.5.ARM CPU:0.6 RAM:514.6/970.8MB NTP:4.5ms/-1.5ppm +27.2C 0/0Acfts[1h] RF:+0-0.4ppm/+18.69dB/+13.0dB@10km[104282]/+9.7dB@10km[2/3] 25 | VITACURA2>APRS,TCPIP*,qAC,GLIDERN3:/042136h3322.81SI07034.95W&/A=002345 v0.2.5.ARM CPU:0.3 RAM:695.0/970.5MB NTP:0.6ms/-5.7ppm +51.5C RF:+0-0.0ppm/+1.32dB 26 | VITACURA2>APRS,TCPIP*,qAC,GLIDERN3:>042136h v0.2.5.ARM CPU:0.3 RAM:695.0/970.5MB NTP:0.6ms/-5.7ppm +52.1C 0/0Acfts[1h] RF:+0-0.0ppm/+1.32dB/+2.1dB@10km[193897]/+9.0dB@10km[10/20] 27 | # 28 | # since 0.2.6 the ogn comment of a receiver beacon is just optional, it also can be a user comment 29 | Ulrichamn>APRS,TCPIP*,qAC,GLIDERN1:/085616h5747.30NI01324.77E&/A=001322 30 | ROBLE3>APRS,TCPIP*,qAC,GLIDERN4:/200022h3258.58SI07100.78W&/A=007229 Contact: achanes@manquehue.net, brito.felipe@gmail.com 31 | # 32 | # ... and user comment can include a 'id' 33 | ALFALFAL>APRS,TCPIP*,qAC,GLIDERN4:/221830h3330.40SI07007.88W&/A=008659 Alfalfal Hidroelectric Plant, Club de Planeadores Vitacurs 34 | -------------------------------------------------------------------------------- /app/model/logbook.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.hybrid import hybrid_property 2 | 3 | from app import db 4 | 5 | 6 | class Logbook(db.Model): 7 | __tablename__ = "logbooks" 8 | 9 | id = db.Column(db.Integer, primary_key=True) 10 | 11 | takeoff_timestamp = db.Column(db.DateTime) 12 | takeoff_track = db.Column(db.SmallInteger) 13 | landing_timestamp = db.Column(db.DateTime) 14 | landing_track = db.Column(db.SmallInteger) 15 | max_altitude = db.Column(db.Float(precision=2)) 16 | 17 | # Relations 18 | sender_id = db.Column(db.Integer, db.ForeignKey("senders.id", ondelete="CASCADE"), index=True) 19 | sender = db.relationship("Sender", foreign_keys=[sender_id], backref=db.backref("logbook_entries", order_by=db.case(whens={True: takeoff_timestamp, False: landing_timestamp}, value=(takeoff_timestamp != db.null())).desc())) 20 | 21 | takeoff_airport_id = db.Column(db.Integer, db.ForeignKey("airports.id", ondelete="CASCADE"), index=True) 22 | takeoff_airport = db.relationship("Airport", foreign_keys=[takeoff_airport_id], backref=db.backref("logbook_entries_takeoff", order_by=db.case(whens={True: takeoff_timestamp, False: landing_timestamp}, value=(takeoff_timestamp != db.null())).desc())) 23 | 24 | takeoff_country_id = db.Column(db.Integer, db.ForeignKey("countries.gid", ondelete="CASCADE"), index=True) 25 | takeoff_country = db.relationship("Country", foreign_keys=[takeoff_country_id], backref=db.backref("logbook_entries_takeoff", order_by=db.case(whens={True: takeoff_timestamp, False: landing_timestamp}, value=(takeoff_timestamp != db.null())).desc())) 26 | 27 | landing_airport_id = db.Column(db.Integer, db.ForeignKey("airports.id", ondelete="CASCADE"), index=True) 28 | landing_airport = db.relationship("Airport", foreign_keys=[landing_airport_id], backref=db.backref("logbook_entries_landing", order_by=db.case(whens={True: takeoff_timestamp, False: landing_timestamp}, value=(takeoff_timestamp != db.null())).desc())) 29 | 30 | landing_country_id = db.Column(db.Integer, db.ForeignKey("countries.gid", ondelete="CASCADE"), index=True) 31 | landing_country = db.relationship("Country", foreign_keys=[landing_country_id], backref=db.backref("logbook_entries_landing", order_by=db.case(whens={True: takeoff_timestamp, False: landing_timestamp}, value=(takeoff_timestamp != db.null())).desc())) 32 | 33 | @hybrid_property 34 | def duration(self): 35 | return None if (self.landing_timestamp is None or self.takeoff_timestamp is None) else self.landing_timestamp - self.takeoff_timestamp 36 | 37 | @duration.expression 38 | def duration(cls): 39 | return db.case(whens={False: None, True: cls.landing_timestamp - cls.takeoff_timestamp}, value=(cls.landing_timestamp != db.null() and cls.takeoff_timestamp != db.null())) 40 | 41 | @hybrid_property 42 | def reference_timestamp(self): 43 | return self.takeoff_timestamp if self.takeoff_timestamp is not None else self.landing_timestamp 44 | 45 | @reference_timestamp.expression 46 | def reference_timestamp(cls): 47 | return db.case(whens={True: cls.takeoff_timestamp, False: cls.landing_timestamp}, value=(cls.takeoff_timestamp != db.null())) 48 | 49 | #__table_args__ = (db.Index('idx_logbook_reference_timestamp', db.case(whens={True: takeoff_timestamp, False: landing_timestamp}, value=(takeoff_timestamp != db.null()))),) 50 | # FIXME: does not work... 51 | 52 | # FIXME: this does not throw an error as the __table_args__ above, but there is no index created 53 | #_wrapped_case = f"({db.case(whens={True: Logbook.takeoff_timestamp, False: Logbook.landing_timestamp}, value=Logbook.takeoff_timestamp != db.null())})" 54 | #Index("idx_logbook_reference_timestamp", _wrapped_case) 55 | 56 | # TODO: 57 | # so execute manually: CREATE INDEX IF NOT EXISTS idx_logbook_reference_timestamp ON logbooks ((CASE takeoff_timestamp IS NULL WHEN true THEN takeoff_timestamp WHEN false THEN landing_timestamp END)); 58 | -------------------------------------------------------------------------------- /app/templates/sender_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 | 7 |
8 |

Sender Details

9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
Name:{{ sender.name }}
Address:{{ sender.address if sender.address else '-' }}
Real Address:{{ sender.real_address if sender.real_address else '-' }}
Stealth:{{ sender.stealth if sender.stealth else '-' }}
Aircraft Type:{{ sender.aircraft_type.name }}
Software Version:{{ sender.software_version if sender.software_version else '-' }}
Hardware Version:{{ sender.hardware_version if sender.hardware_version else '-' }}
First seen:{{ sender.firstseen }}
Last seen:{{ sender.lastseen }}
20 |
21 | 22 |
23 |

Sender Info

24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {% for info in sender.infos %} 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {% endfor %} 41 |
AircraftRegistrationCompetition SignAircraft TypeSource
{{ info.aircraft }}{{ info|to_html_flag|safe }}{{ info.registration }}{{ info.competition }}{{ info.aircraft_type.name }}{{ info.address_origin.name }}
42 |
43 | 44 |
45 |

Range View

46 | 47 |
48 | 49 |
50 |

Logbook

51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | {% set ns = namespace(mydate=none) %} 74 | {% for entry in sender.logbook_entries %} 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | {% endfor %} 86 | 87 |
AirportTime UTC
#DateTakeoffLandingTakeoffLandingDurationAGL
{{ loop.index }}{% if ns.mydate != entry.reference_timestamp.strftime('%Y-%m-%d') %}{% set ns.mydate = entry.reference_timestamp.strftime('%Y-%m-%d') %}{{ ns.mydate }}{% endif %}{% if entry.takeoff_airport is not none %}{{ entry.takeoff_airport.name }}{% endif %}{% if entry.landing_airport is not none %}{{ entry.landing_airport.name }}{% endif %}{% if entry.takeoff_timestamp is not none %} {{ entry.takeoff_timestamp.strftime('%H:%M') }} {% endif %}{% if entry.landing_timestamp is not none %} {{ entry.landing_timestamp.strftime('%H:%M') }} {% endif %}{% if entry.duration is not none %}{{ entry.duration }}{% endif %}{% if entry.max_altitude is not none %}{{ '%0.1f'|format(entry.max_altitude - entry.takeoff_airport.altitude) }} m{% endif %}
88 |
89 | 90 |
91 | 92 | {% endblock %} 93 | -------------------------------------------------------------------------------- /app/utils.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | from datetime import datetime, timedelta 3 | 4 | from flask import current_app 5 | 6 | from aerofiles.seeyou import Reader 7 | from ogn.parser.utils import FEETS_TO_METER 8 | 9 | from .model import AircraftType, SenderInfoOrigin, SenderInfo, Airport, Location 10 | 11 | 12 | address_prefixes = {"F": "FLR", "O": "OGN", "I": "ICA"} 13 | 14 | nm2m = 1852 15 | mi2m = 1609.34 16 | 17 | 18 | def get_days(start, end): 19 | days = [start + timedelta(days=x) for x in range(0, (end - start).days + 1)] 20 | return days 21 | 22 | 23 | def date_to_timestamps(date): 24 | start = datetime(date.year, date.month, date.day, 0, 0, 0) 25 | end = datetime(date.year, date.month, date.day, 23, 59, 59) 26 | return (start, end) 27 | 28 | 29 | def get_trackable(sender_info_dicts): 30 | result = [] 31 | for sender_info_dict in sender_info_dicts: 32 | if sender_info_dict['tracked'] and sender_info_dict['address_type'] in address_prefixes: 33 | result.append("{}{}".format(address_prefixes[sender_info_dict['address_type']], sender_info_dict['address'])) 34 | return result 35 | 36 | 37 | def get_airports(cupfile): 38 | airports = list() 39 | with open(cupfile) as f: 40 | for line in f: 41 | try: 42 | for waypoint in Reader([line]): 43 | if waypoint["style"] > 5: # reject unlandable places 44 | continue 45 | 46 | airport = Airport() 47 | airport.name = waypoint["name"] 48 | airport.code = waypoint["code"] 49 | airport.country_code = waypoint["country"] 50 | airport.style = waypoint["style"] 51 | airport.description = waypoint["description"] 52 | location = Location(waypoint["longitude"], waypoint["latitude"]) 53 | airport.location_wkt = location.to_wkt() 54 | airport.altitude = waypoint["elevation"]["value"] 55 | if waypoint["elevation"]["unit"] == "ft": 56 | airport.altitude = airport.altitude * FEETS_TO_METER 57 | airport.runway_direction = waypoint["runway_direction"] 58 | airport.runway_length = waypoint["runway_length"]["value"] 59 | if waypoint["runway_length"]["unit"] == "nm": 60 | airport.altitude = airport.altitude * nm2m 61 | elif waypoint["runway_length"]["unit"] == "ml": 62 | airport.altitude = airport.altitude * mi2m 63 | airport.frequency = waypoint["frequency"] 64 | 65 | airports.append(airport) 66 | except AttributeError as e: 67 | current_app.logger.error("Failed to parse line: {} {}".format(line, e)) 68 | 69 | return airports 70 | 71 | 72 | def open_file(filename): 73 | """Opens a regular or unzipped textfile for reading.""" 74 | f = open(filename, "rb") 75 | a = f.read(2) 76 | f.close() 77 | if a == b"\x1f\x8b": 78 | f = gzip.open(filename, "rt", encoding="latin-1") 79 | return f 80 | else: 81 | f = open(filename, "rt", encoding="latin-1") 82 | return f 83 | 84 | 85 | def get_sql_trustworthy(source_table_alias): 86 | MIN_DISTANCE = 1000 87 | MAX_DISTANCE = 640000 88 | MAX_NORMALIZED_QUALITY = 40 # this is enough for > 640km 89 | MAX_ERROR_COUNT = 9 90 | MAX_CLIMB_RATE = 50 91 | 92 | return f""" 93 | ({source_table_alias}.distance IS NOT NULL AND {source_table_alias}.distance BETWEEN {MIN_DISTANCE} AND {MAX_DISTANCE}) 94 | AND ({source_table_alias}.normalized_quality IS NOT NULL AND {source_table_alias}.normalized_quality <= {MAX_NORMALIZED_QUALITY}) 95 | AND ({source_table_alias}.error_count IS NULL OR {source_table_alias}.error_count <= {MAX_ERROR_COUNT}) 96 | AND ({source_table_alias}.climb_rate IS NULL OR {source_table_alias}.climb_rate BETWEEN -{MAX_CLIMB_RATE} AND {MAX_CLIMB_RATE}) 97 | """ 98 | -------------------------------------------------------------------------------- /app/commands/gateway.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime, timezone 3 | import time 4 | 5 | from flask import current_app 6 | from flask.cli import AppGroup 7 | import click 8 | from tqdm import tqdm 9 | 10 | from ogn.client import AprsClient 11 | 12 | from app import redis_client 13 | from app.gateway.beacon_conversion import aprs_string_to_message 14 | from app.gateway.message_handling import receiver_status_message_to_csv_string, receiver_position_message_to_csv_string, sender_position_message_to_csv_string 15 | from app.collect.gateway import transfer_from_redis_to_database 16 | 17 | user_cli = AppGroup("gateway") 18 | user_cli.help = "Connection to APRS servers." 19 | 20 | 21 | @user_cli.command("run") 22 | @click.option("--aprs_filter", default='') 23 | def run(aprs_filter): 24 | """ 25 | Run the aprs client, parse the incoming data and put it to redis. 26 | """ 27 | 28 | import logging 29 | logging.basicConfig(level=logging.INFO, format='%(asctime)s %(name)-17s %(levelname)-8s %(message)s') 30 | 31 | current_app.logger.warning("Start ogn gateway") 32 | client = AprsClient(current_app.config['APRS_USER'], aprs_filter) 33 | client.connect() 34 | 35 | def insert_into_redis(aprs_string): 36 | # Convert aprs_string to message dict, add MGRS Position, flatten gps precision, etc. etc. ... 37 | message = aprs_string_to_message(aprs_string) 38 | if message is None: 39 | return 40 | 41 | # separate between tables (receiver/sender) and aprs_type (status/position) 42 | if message['beacon_type'] in ('aprs_receiver', 'receiver'): 43 | if message['aprs_type'] == 'status': 44 | redis_target = 'receiver_status' 45 | csv_string = receiver_status_message_to_csv_string(message, none_character=r'\N') 46 | elif message['aprs_type'] == 'position': 47 | redis_target = 'receiver_position' 48 | csv_string = receiver_position_message_to_csv_string(message, none_character=r'\N') 49 | else: 50 | return 51 | else: 52 | if message['aprs_type'] == 'status': 53 | return # no interesting data we want to keep 54 | elif message['aprs_type'] == 'position': 55 | redis_target = 'sender_position' 56 | csv_string = sender_position_message_to_csv_string(message, none_character=r'\N') 57 | else: 58 | return 59 | 60 | mapping = {csv_string: str(time.time())} 61 | 62 | redis_client.zadd(name=redis_target, mapping=mapping, nx=True) 63 | insert_into_redis.beacon_counter += 1 64 | 65 | current_minute = datetime.utcnow().minute 66 | if current_minute != insert_into_redis.last_minute: 67 | current_app.logger.info(f"{insert_into_redis.beacon_counter:7d}/min") 68 | insert_into_redis.beacon_counter = 0 69 | insert_into_redis.last_minute = current_minute 70 | 71 | insert_into_redis.beacon_counter = 0 72 | insert_into_redis.last_minute = datetime.utcnow().minute 73 | 74 | try: 75 | client.run(callback=insert_into_redis, autoreconnect=True) 76 | except KeyboardInterrupt: 77 | current_app.logger.warning("\nStop ogn gateway") 78 | 79 | client.disconnect() 80 | 81 | 82 | @user_cli.command("transfer") 83 | def transfer(): 84 | """Transfer data from redis to the database.""" 85 | 86 | transfer_from_redis_to_database() 87 | 88 | 89 | @user_cli.command("printout") 90 | @click.option("--aprs_filter", default='') 91 | def printout(aprs_filter): 92 | """Run the aprs client and just print out the data stream.""" 93 | 94 | current_app.logger.warning("Start ogn gateway") 95 | client = AprsClient(current_app.config['APRS_USER'], aprs_filter=aprs_filter) 96 | client.connect() 97 | 98 | try: 99 | client.run(callback=lambda x: print(f"{datetime.utcnow()}: {x}"), autoreconnect=True) 100 | except KeyboardInterrupt: 101 | current_app.logger.warning("\nStop ogn gateway") 102 | 103 | client.disconnect() 104 | --------------------------------------------------------------------------------