Webhafen - Projekte und Bastelkram

Flugzeuge loggen

Mein Ziel war es, alle Flugzeuge, die mein Flightradar so Tag und Nacht sieht, in eine Datenbank zu schreiben. Das stellte sich gar nicht als so leicht heraus, vor allem für einen Programmier-Neuling wie mich.

Benutzt habe ich dafür Python als Programmiersprache. Da ich gerade angefangen hatte, Python zu lernen, bot sich das geradezu an. Herausgekommen ist ein, sicher nicht elegantes, aber doch funktionsfähiges Script welches auf dem Raspberry sogar als Service läuft. Es schreibt alle eindeutig gesehenen Flugzeuge in eine SQlite-Datenbank. Dabei reichen mir Informationen wie Registrierung, Typ, Airline, wann das erste mal und wie oft gesehen, wobei bei letzterem ein mal pro Tag meinen Ansprüchen genügt.

Als erstes musste ich herausfinden wo der Empfänger die Daten hinschreibt bzw. wie ich an die Daten herankomme. Was dabei aus dem Receiver herauskommt ist durchaus rudimentär. Denn ich musste feststellen, dass auch das Frontend tar1090 aus dem eigenen Flightradar die meisten Daten sich selbst zusammensetzt.

Lediglich die ID, immerhin eindeutig einem Flugzeug zugeordnet, und diverse Statusmeldungen sowie Bewegungsdaten mit Höhe und Position gibt der Receiver heraus. Meinem Zweck nützt hier nur die ID, wie sich gleich zeigen wird.

Die aircraft.json

Die Rohdaten werden vom Receiver in eine json-Datei geschrieben, ist auf dem Pi liegt. Im Browser kann man sie mit

http://IP-des-PI/tar1090/data/aircraft.json

anzeigen lassen.

Hier sieht das im Moment der Erstellung des Artikels so aus:

{ "now" : 1657285122.0, "messages" : 1537450, "aircraft" : [ 
{"hex":"407569","type":"adsb_icao","flight":"EZY6939",
"alt_baro":37050,"alt_geom":38300,"gs":513.5,"ias":250,"tas":442,
"mach":0.776,"wd":322,"ws":79,"oat":-59,"tat":-34,"track":119.26,
"track_rate":-0.06,"roll":-0.53,"mag_heading":111.45,
"true_heading":115.23,"baro_rate":0,"geom_rate":0,"squawk":"0751",
"emergency":"none","category":"A3","nav_qnh":1013.6,
"nav_altitude_mcp":36992,"nav_heading":0.00,"lat":53.935272,
"lon":10.261623,"nic":8,"rc":186,"seen_pos":1.2,"version":2,
"nic_baro":1,"nac_p":9,"nac_v":1,"sil":3,"sil_type":"perhour",
"gva":2,"sda":2,"alert":0,"spi":0,"mlat":[],"tisb":[],
"messages":2094,"seen":0.7,"rssi":-18.9}, {"hex":"300189","type":"adsb_icao","flight":"NOS9510",
"alt_baro":13800,"alt_geom":14525,"gs":328.3,"ias":242,"tas":298,
"mach":0.468,"wd":330,"ws":28,"oat":-2,"tat":10,"track":167.87,
"track_rate":0.03,"roll":-0.35,"mag_heading":165.76,
"true_heading":169.55,"baro_rate":2560,"geom_rate":2496,
"squawk":"1123","emergency":"none","category":"A3",
"nav_qnh":1013.6,"nav_altitude_mcp":24000,"nav_altitude_fms":37008,
"nav_heading":165.23,"lat":53.500020,"lon":10.263088,"nic":8,
"rc":186,"seen_pos":0.7,"version":2,"nic_baro":1,"nac_p":11,
"nac_v":2,"sil":3,"sil_type":"perhour","gva":2,"sda":2,"alert":0,
"spi":0,"mlat":[],"tisb":[],"messages":2165,"seen":0.7,
"rssi":-15.3}, 
{"hex":"400a5b","type":"adsb_icao","flight":"BAW969H",
"alt_baro":6250,"alt_geom":6800,"gs":263.7,"ias":249,"tas":232,
"mach":0.420,"track":252.11,"track_rate":-2.16,"roll":-23.91,
"mag_heading":255.23,"true_heading":258.90,"baro_rate":3392,
"geom_rate":3168,"squawk":"1356","emergency":"none",
"category":"A3","nav_qnh":1012.8,"nav_altitude_mcp":16000,
"lat":53.701584,"lon":9.824132,"nic":8,"rc":186,"seen_pos":0.8,
"version":2,"nic_baro":1,"nac_p":9,"nac_v":1,"sil":3,
"sil_type":"perhour","gva":2,"sda":2,"alert":0,"spi":0,
"mlat":[],"tisb":[],"messages":262,"seen":0.8,"rssi":-18.0}, ] }

Und so weiter. Der Übersichtlichkeit halber habe ich sie nach drei Einträgen gekürzt. Also ziemlich verwirrend und unübersichtlich. Vor allem, wenn man das erste mal so eine Datei sieht.

Aber da json ein recht strukturiertes Format ist, lässt sich auch so schon einiges herauslesen. Das Prinzip beruht auf Paaren aus Schlüssel und Wert wie z.B. "flight":"CTM1080". Diese sind in geschweiften Klammern und, wenn mehrere Paare vorhanden sind, duch Kommas getrennt. Wer mehr zum json-Format wissen möchte, der findet eine recht gute Erklärung bei Oracle.

Nehmen wir den ersten Teil:

{ "now" : 1657285122.0, "messages" : 1537450, "aircraft" : [
  • now - Hier haben wir das aktuelle Datum - in Sekunden seit dem 01.01.1970 0:00 Uhr GMT
  • messages - Alle Messages die der Receiver seit dem Start empfangen hat
  • aircraft - Ab hier wird es interessant. Denn hier kommt ein Array - erkennbar an der eckigen Klammer - in dem alle aktuell empfangenen Flugzeug-IDs mit weiteren Informationen aufgelistet sind

Die im Schlüssel aircraft enthaltenen Daten wären dann der zweite Teil. Eine genaue Erklärung der einzelnen Schlüssel ist hier zu finden.

{"hex":"407569","type":"adsb_icao","flight":"EZY6939",
"alt_baro":37050,"alt_geom":38300,"gs":513.5,"ias":250,"tas":442,
"mach":0.776,"wd":322,"ws":79,"oat":-59,"tat":-34,"track":119.26,
"track_rate":-0.06,"roll":-0.53,"mag_heading":111.45,
"true_heading":115.23,"baro_rate":0,"geom_rate":0,"squawk":"0751",
"emergency":"none","category":"A3","nav_qnh":1013.6,
"nav_altitude_mcp":36992,"nav_heading":0.00,"lat":53.935272,
"lon":10.261623,"nic":8,"rc":186,"seen_pos":1.2,"version":2,
"nic_baro":1,"nac_p":9,"nac_v":1,"sil":3,"sil_type":"perhour", 
"gva":2,"sda":2,"alert":0,"spi":0,"mlat":[],"tisb":[],
"messages":2094,"seen":0.7,"rssi":-18.9}

Dieser aus dem obigen kompletten Listing herauskopierter Bereich enthält die Daten eines Flugzeugs von Easyjet, erkennbar am EZY. Aus den ganzen Daten könnten für meinen Zweck nur zwei dienen:

  • hex - ein eindeutiger Wert, der von der ICAO - der Internationalen Zivilluftfahrtorganisation der Veinten Nationen - vergeben wird. Vergleichbar ist er z.B. mit der Fahrgestellnummer eines Autos oder der IMEI-Nr. eines Mobiltelefons.
  • flight - Die Flugnummer des Flugs

Wirklich eindeutig ist hier nur der hex-Wert. Alles andere hilft nicht weiter. Die Flugnummer kann ein Tag später ein anderes Flugzeug haben. Es fliegt ja nicht immer das gleiche Flugzeug den gleichen Flug. Und auf dem Rückflug ist die Flugnummer anders.

Also nehme ich aus diesen Informationen nur den hex-Wert.

Die Datenbank

Als nächstes ging es daran, festzulegen, welche Werte für mich hier interessant sind. Ich wollte eine Übersicht über alle eindeutig von Receiver gesehenen Flugzeuge.

Ich musste nun also festlegen, welche Daten in die  Datenbank geschrieben werden sollen. Mich interessierte

  • der hex-Wert als eindeutiges Kennzeichen
  • die Registrierung, also sozusagen das Kennzeichen wie bei einem Auto, des Flugzeugs
  • der Hersteller und der Typ
  • die Airline, die das Flugzeug betreibt
  • wann das Flugzeug das erste und das letzte mal gesehen wurde
  • wie oft das Flugzeug gesehen wurde, wobei mir hier ein mal am Tag reicht - also ein Zähler

Das sah dann am Ende so aus - hier mit Daten

datenbank

Vor der Erstellung der Datenbank sollte man sich noch überlegen, welchen Typ die Daten in den einzelnen Spalten haben. Ich hatte mich dafür entschieden noch eine id für mich einzufügen. Diese wird mit jedem Eintrag eins nach oben gezählt. Da auch der hex-Wert eindeutig ist, hätte auch dieser als id gereicht. In der Datenbank nennt man das dann auch den Primary Key.

Welche Typen habe ich also festgelegt?

Spalte Typ
id integer Primary Key
hex tinytext
registration tinytext
manufacturer text
type text
airline text
firstseen date
lastseen date
count integer

So, damit hätten wir alle Deklarationen. Integer erlaubt in der Datenbank dann nur Zahlen, tinxtext nur einen kurzen Text bis 255 Zeichen, was ich allerdings auch noch eingrenzen könnte, text darf dann auch länger werden und date ergibt einen Datumswert.

Das Script

Was soll das Script nun tun? Erst ein mal die Grundfunktionen.

  • Es soll die Datenbank öffnen - oder, wenn nicht vorhanden, anlegen
  • Es soll die Daten von Receiver laden, auch wenn es nicht auf dem Pi selbst laufen würde
  • Es soll die Daten, die es vom Receiver bekommt, in die Datenbank schreiben
  • Dabei soll es prüfen, ob schon ein Eintrag zu dem Flugzeug existiert
  • Wenn ein Eintrag existiert soll es prüfen, ob das Datum unterschiedlich ist und dann den Zähler nach oben zählen
  • Und es soll das alle 30 Sekunden machen, da die aircraft.json im Sekundentakt aktualisiert wird

Bei der letzten Anforderung reichen mir 30 Sekunden. Die Wahrscheinlichkeit, dass ein Flugzeug kürzer als 30 Sekunden empfangen wird, ist zwar gegeben, aber nicht unbedingt die Regel.

Was wir hier brauchen, sind wenige Zeilen Code, wie ich selbst lernen musste. Ich dachte, das ganze wäre aufwendiger. Sicher geht es sogar noch einfacher, aber ich hatte gerade angefangen, Python zu lernen.

Python kann von sich aus schon recht viel, einiges muss aber als Modul importiert werden, damit die passende Funktion benutzt werden kann. Immerhin ist alles davon schon in einer Standardinstallation von Python vorhanden und es muss nichts weiter Installiert werden.

import sqlite3
from contextlib import closing
from urllib.request import urlopen, URLError
import json
import time

Wir brauchen also 

  • sqlite3 um die Datenbank zu benutzten. Ich hatte mich hier für sqlite entschieden weil es einfach in eine Datei schreibt und keinen Server dazu braucht. 
  • Von contextlib benutze ich closing um hier Code zu sparen, der Verbindungen öffnet und schließt. Zumindest habe ich es so verstanden.
  • Aus urllib.request das Teil urlopen um die aircraft.json von einem Webserver abzurufen.
  • json um die json-Datei direkt zu laden und in einem benutzbaren Format zu haben. 
  • Und time um mit Datumsangaben zu arbeiten.

Um später Tipparbeit zu sparen deklariere ich ein paar Variablen. Damit muss ich sie auch nur zentral an einer Stelle ändern, sollten Sie sich mal ändern.

#define user variables
db="flights.db"     #database file path/name
tb="flights"        #table name in database
url="http://IP-des-PI/tar1090/data/aircraft.json" #url of aircraft.json

Ich benenne die Datenbank und die Tabelle in der Datenbank, die dann die Daten enthalten soll. Und ich lege fest, von wo die Daten kommen sollen. Das hinter der # ist dann ein Kommentar.

Im Anschluss habe ich mir zwei Funktionen geschrieben, die später aufgerufen werden. Das muss nicht extra in Funktionen verpackt werden und kann auch direkt als Ablauf aufgerufen werden, doch ich kann mir die später einfach rauskopieren, wenn ich sie für ein anderes Projekt verwenden will.

def db_conn(db_file):
    con = None
    try:
        con=sqlite3.connect(db_file)
        return con
    except Error as e:
        print(e)

Dies ist recht eindeutig. Ich versuche eine Verbindung zur Datenbank zu erstellen. Geht es gut, gibt die Funktion die Verbindung zurück, ansonsten bricht sie ab und gibt eine Fehlermeldung aus. Existiert die Datenbankdatei, dann wird sie geöffnet, existiert sie nicht, dann wird sie erstellt.

def data_import(url):
    with closing(urlopen(url, None, 5.0)) as aircraft_file:
        aircraft_data = json.load(aircraft_file)
    return aircraft_data

Hier importiere ich die Daten des Reveivers. Ich öffne die oben erwähnte url, packe die Daten daraus in aircraft_file und schreibe sie mit json.load in die Variable aircraft_data.

Nun ist es an der Zeit, die Funktionen, die hier definiert sind, auch zu benutzen.

c=db_conn(db)
c.cursor()
istable=c.execute("SELECT tbl_name FROM sqlite_master").fetchall()
if istable == []:
    statement="CREATE TABLE " + tb + " ( id integer PRIMARY KEY, icaohex tinytext, registration tinytext, 
               manufacturer text, type text, airline text, firstseen date, lastseen date, count integer )"
    c.execute(statement)

Die erste Zeile ruft die Funktion auf, die die Verbindung zur Datenbank herstellt. Dabei übergebe ich ihr die Variable db, die weiter oben den Dateinamen der Datenbank enthält.  Wenn das funktioniert wird die Verbindung in die Variable c geschrieben und mit c.cursor() daran gebunden. Damit kann ich c. später für alle Datenbankoperationen nutzen.

In Zeile drei schicke ich eine Abfrage an die Datenbank. Ich möchte alle Tabellen, die in der Datenbank existieren. Diese kommen als Liste zurück und werden in die Variable istable geschrieben.

if istable prüft dann, ob Tabellen existieren. Wenn nicht, also das Ergebnis der vorherigen Abfrage das leere [] ergibt, wird sie angelegt. Als erstes setze ich den SQL-Befehl in eine Variable. Das mache ich für mich zur Übersichtlichkeit. Er kann auch, wie vorher, hinter c.execute in die Klammern.

CREATE TABLE legt die Tabelle an, deren Namen in der Variable tb definiert ist, und die Werte in Klammern definieren die einzelnen Spalten, die in der Tabelle vorhanden sein sollen. Diese sind schon weiter oben erklärt.

c.execute führt dann den SQL-Befehlt aus.

Existiert schon eine Tabelle, dann wird der komplette Teil nach dem Doppelpunkt von if istable ignoriert.

Als nächstes wird die zweite Funktion aufgerufen.

airdata=data_import(url)
print(airdata)

Für das fertige Script reicht die erste Zeile. Mit der zweiten lasse ich mir nur das Ergebnis auf der Konsole ausgeben.

Ausgabe auf der Konsole

Wie auf dem Bild zu sehen, ist jetzt also in der Variable airdata die komplette json-Datei in einem Stück. Das funktioniert also. Die zweite Zeile kann für das fertige Script wieder weg.

Und dann kommt der letzte Teil des Scripts. Wir holen die Daten aus dem json in der Variable und schreiben sie in die Datenbank.

airdata=data_import(url)
    for a in airdata['aircraft']:

Wir greifen auf airdata zu und nehmen den Bereich aircraft - wie oben beschrieben sind da die Daten zu den einzelnen Flugzeugen drin. Mit der for-Schleife gehen wir durch jeden einzelnen Eintrag. Ein Eintrag ist, wie oben beschrieben je eine Auflisten in den geschweiften Klammern.

        hex = a.get('hex')

Daraus greifen wir uns den Wert hinter dem Schlüsselwort hex heraus.

        flight = ""
        manufacturer=""
        ptype=""
        airline=""
        zeit=str(time.strftime("%d.%m.%Y"))

Alle anderen Daten setzen wir auf einen leeren Eintrag, da diese Daten uns nicht vorliegen. Die Zeit, also in unserem Fall das Datum setzen wir auf das aktuelle Datum im Format 01.01.2022 und formatieren es als String.

        if hex:

Wenn wir einen Eintrag in hex haben, dann machen wir weiter.

            statement="SELECT * FROM " + tb + " WHERE icaohex='" + hex + "'"
            icaoexist=c.execute(statement).fetchall()

Wir prüfen, ob der hex-Wert schon in der Datenbank enthalten ist.

            if icaoexist:

Wenn dem so ist, aktualisieren wir den Eintrag und setzen den Eintrag für die letzte Sichtung auf das aktuelle Datum. Der Counter wird um 1 erhöht.

                for z in icaoexist:
                    if z[7] != zeit:
                        statement="UPDATE " + tb + " SET lastseen='" + zeit + "', count=count+1 WHERE icaohex='" + hex + "'"
                        c.execute(statement)
                        c.commit()

Und wenn nicht schreiben wir die Daten direkt in die Datenbank.

            else:
                statement="INSERT INTO " + tb + " (icaohex, registration, manufacturer, type, airline, 
                           firstseen, lastseen, count) VALUES ('" + str(hex) + "','" + str(flight) + "','" 
                           + manufacturer + "','" + ptype + "','" + airline + "','" + zeit + "','" + zeit 
                           + "','1')"
                c.execute(statement)
                c.commit()

Und durch die for-Schleife macht das Script das so lange, bis alle hex-Einträge aus dem aircraft-Bereich der json-Daten abgearbeitet sind.

Nun wollte ich das Script nicht immer per Hand aufrufen. Es sollte alle 30 Sekunden die Daten abrufen und in die Datenbank schreiben. Dafür verpacken wir alles in einen weiteren Bereich.

try:
    while True:
        airdata=data_import(url)
...
                c.commit()
        time.sleep(30)
except KeyboardInterrupt:
    c.close()

Ich habe jetzt etwas gekürzt. das try testet, ob in dem folgen Block, der bis time.sleep geht, ein Fehler auftaucht. Funktioniert alles, dann geht es weiter zu while True. Und da immer True ist, bis wir dem Programm sagen, das kein True mehr ist, ist die Bedingung immer erfüllt und der folgende Block bis time.sleep läuft immer wieder in einer Schleife. Damit die aber nicht sofort neu startet sondern eben die 30 Sekunden wartet, steht in der Klammer hinter time.sleep eine 30.

Da wir hier aber nie dafür sorgen, dass True doch mal zu False gibt, bauen wir noch einen Notausstieg ein. Die letzten zwei Zeilen machen das. except unterbricht das try, in diesem Fall durch ein KeyboardInterrupt. Also, wenn wir Ctrl+C oder Ctrl+Z drücken. Dann wird das Script abgebrochen und die Datenbank geschlossen.

Fertig ist das Script. Wie schon geschrieben, das geht sicher auch eleganter. Dafür, dass es mein erstes Python Script ist, bin ich sehr zufrieden damit. Hier noch mal mein ganzes Script, wie es hier sehr zuverlässig läuft. Am Ende des Artikels ist es auch noch zum Download als zip-Datei angefügt. Ich habe ihm, da es hier problemlos läuft, die Versionsnummer 1.0 gegeben. Updates und Veränderungen werde ich entsprechend hinzufügen.

import sqlite3
from contextlib import closing
from urllib.request import urlopen, URLError
import json
import time

#define user variables
db="flights.db"     #database file path/name
tb="flights"        #table name in database
url="http://IP-des-PI/tar1090/data/aircraft.json" #url of aircraft.json

def db_conn(db_file):
    con = None
    try:
        con=sqlite3.connect(db_file)
        return con
    except Error as e:
        print(e)

def data_import(url):
    with closing(urlopen(url, None, 5.0)) as aircraft_file:
        aircraft_data = json.load(aircraft_file)
    return aircraft_data

c=db_conn(db)
c.cursor()
istable=c.execute("SELECT tbl_name FROM sqlite_master").fetchall()
if istable == []:
    statement="CREATE TABLE " + tb + " ( id integer PRIMARY KEY, icaohex tinytext, registration tinytext, 
               manufacturer text, type text, airline text, firstseen date, lastseen date, count integer )"
    c.execute(statement)

try:
    while True:
        airdata=data_import(url)
        for a in airdata['aircraft']:
            hex = a.get('hex')
            flight = ""
            manufacturer=""
            ptype=""
            airline=""
            zeit=str(time.strftime("%d.%m.%Y"))
            if hex:
                statement="SELECT * FROM " + tb + " WHERE icaohex='" + hex + "'"
                icaoexist=c.execute(statement).fetchall()
                if icaoexist:
                    for z in icaoexist:
                        if z[7] != zeit:
                            statement="UPDATE " + tb + " SET lastseen='" + zeit + "', count=count+1 
                                       WHERE icaohex='" + hex + "'"
                            c.execute(statement)
                            c.commit()
                else:
                    statement="INSERT INTO " + tb + " (icaohex, registration, manufacturer, type, 
                               airline, firstseen, lastseen, count) VALUES ('" + str(hex) + "','"
                               + str(flight) + "','" + manufacturer + "','" + ptype + "','" + airline 
                               + "','" + zeit + "','" + zeit + "','1')"
                    c.execute(statement)
                    c.commit()
        time.sleep(30)
except KeyboardInterrupt:
    c.close()

Wie die restlichen Daten in die Datenbank kommen, verrate ich im nächsten Artikel.

Das Script als Service

Damit ich das Script nicht immer neu starten muss, wenn der Pi mal vom Strom war oder etwas anderes sich gehängt hat, richte ich es noch auf dem Pi als Service ein. Es gibt natürlich noch die Möglichkeit, es regelmässig per Cron-Job zu starten, also praktisch eine Zeitplanung, die zu bestimmten Zeiten ein Programm ausführt, doch das finde ich nicht so elegant.

Als erstes schiebe ich das Script auf den Pi in den Ordner /home/dietpi. Wer Windows benutzt, der kann das aus der Windows Powershell mit

scp flights.py user@IP-des-Pi:/home/dietpi

machen. user wird durch den Benutzernamen und IP-des-PI wie schon vorher mit der IP des Pi ersetzt. flights.py ist der Dateiname, in der das Script gespeichert ist.

Dann logge ich mich mit SSH im Pi ein. Wie das geht, ist im Bereich Installation beschrieben. Mit

cd /lib/systemd/system/

wechsele ich in das Systemverzeichnis. Und mit 

sudo nano flights.service

lege ich eine neue Datei an, die auch gleich mit einem Editor geöffnet wird. In diese Datei wird nun folgendes eingetragen:

####
[Unit]
Description=FlightLogger
After=multi-user.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /home/dietpi/flights.py
Restart=on-abort

[Install]
WantedBy=multi-user.target
###

Ctrl+S zum Speichern und Ctrl+X beendet den Editor.

Folgende Befehle machen dann den Service, den ich gerade angelegt habe, ausführbar und sagen dem System, dass es ihn gibt und starten ihn auch gleich das erste mal.

sudo chmod 644 /lib/systemd/system/flights.service
chmod +x /home/pi/flights.py
sudo systemctl daemon-reload
sudo systemctl enable flights.service
sudo systemctl start flights.service

Stürzt das Script aus irgend einem Grund ab oder wird es beendet, dann startet es automatisch neu.

Fertig ist meine Eigenbau-Lösung für einen Flugzeug-Logger. Solltet Ihr Anregungen oder Hinweise haben, bin ich da gerne offen für. Schreibt mir dann einfach eine Mail.

Flightlogger Version 1.0