diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..da5a910 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +spots \ No newline at end of file diff --git a/.idea/cssxfire.xml b/.idea/cssxfire.xml new file mode 100644 index 0000000..61699e7 --- /dev/null +++ b/.idea/cssxfire.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/deployment.xml b/.idea/deployment.xml new file mode 100644 index 0000000..222065d --- /dev/null +++ b/.idea/deployment.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/dictionaries/mm.xml b/.idea/dictionaries/mm.xml new file mode 100644 index 0000000..3aabd29 --- /dev/null +++ b/.idea/dictionaries/mm.xml @@ -0,0 +1,12 @@ + + + + adsb + asctime + icao + levelname + squitter + sqwk + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..97626ba --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..8086a31 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..550961c --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + Bash + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e4c7356 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/spots.iml b/.idea/spots.iml new file mode 100644 index 0000000..4563498 --- /dev/null +++ b/.idea/spots.iml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vagrant.xml b/.idea/vagrant.xml new file mode 100644 index 0000000..a5aa786 --- /dev/null +++ b/.idea/vagrant.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..4788a8a --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 731c1b8..e56b2c9 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,10 @@ The following message will be decoded: * Comm BDS identity reply (Downlink format: 21) Messages decoded are displayed either in a serialised format on standard output -or in a tabular format depending on preference. +or in a tabular format depending on preference. An inbuilt server is listening on port 5051 (configurable) and +enables a client to access decoded messages in json format. -Some statistics is collected. +Some statistics is collected, this data is also accessible through the server ## Dependencies @@ -98,6 +99,7 @@ Configuration for spots is in `spots_config.json`. Follows json syntax with no e * verbose logging (true/false): writes messages to spots logfile * check crc (true/false): whether to check crc (recommended) or not +* check phase (true/false): simple check if there is a phase shift and correction * use metric (true/false): show values in metric system or not (altitude and velocity) * apply bit error correction (true/false): whether to try to correct bit errors or not (CPU demanding if true) * read from file (true/false): if true, read samples from a file rather than from the USB dongle @@ -109,14 +111,40 @@ Configuration for spots is in `spots_config.json`. Follows json syntax with no e * log file (string): The name of the log file * log max bytes (integer): How many bytes to log before the log file is rotated * log backup count (integer): How many roted log files to keep +* spots server address (localhost or ip-address): the address for the server +* spots server port (5051): the server port + +## Client/Server + +Use nginx as proxy server with the following added to the nginx conf file + + location /spots { + try_files $uri $uri/ $uri/index.html $uri.html @spots; + } + + location @spots { + proxy_pass http://rpi2.local:8080; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + } + +So, nginx will forward any http requests to spots (e.g. `http://www.viltstigen.se/spots`) to +`http://rpi2.local:8080` (spots runs on rpi2-node). + +Flask is running using Gunicorn, listening on port 8080, see `emitter.py` and `spots_emitter.conf` for details. +Use `supervisor` to control processes running as daemons. +The flask application communicates with the radar application (that listen on port 5051) through a simple text +protocol, see files `emitter.py` and `server.py` + +Using some html, bootstrap css and javascripts (jQuery and Highcharts), see files spots.html and spots.js, it is +possible to get this view in a web browser. + +![preamble](spots.png) ## What's next? There is probably inconsistencies, bugs, optimizations, documentation etc etc to make. If you find something, let me know but be aware that this is a leisure thing for me. - -Current directions are: - -* decode more information from received messages -* do some more statistical collection -* add web server/client possibilities \ No newline at end of file diff --git a/basic.py b/basic.py index d929cb4..ee29a85 100644 --- a/basic.py +++ b/basic.py @@ -1,5 +1,8 @@ import threading import json +import time + +__author__ = 'Wolfrax' """ basic implement fundamentals of spots, the class ADSB includes constants, pre-defined tables and configuration @@ -35,7 +38,7 @@ class ADSB: This class defines fundamental constants and is not supposed to be instantiated """ - VERSION = "1.0" + VERSION = "2.0" # basic constants MODES_SIGMIN = 0 @@ -67,39 +70,39 @@ class ADSB: KPH_PER_KNOT = 1.852 # Downlink formats - DF_SHORT_AIR2AIR_SURVEILLANCE_0 = 0 - DF_UNKNOWN_1 = 1 - DF_UNKNOWN_2 = 2 - DF_UNKNOWN_3 = 3 - DF_SURVEILLANCE_ALTITUDE_REPLY_4 = 4 - DF_SURVEILLANCE_IDENTITY_REPLY_5 = 5 - DF_UNKNOWN_6 = 6 - DF_UNKNOWN_7 = 7 - DF_UNKNOWN_8 = 8 - DF_UNKNOWN_9 = 9 - DF_UNKNOWN_10 = 10 - DF_ALL_CALL_REPLY_11 = 11 - DF_UNKNOWN_12 = 12 - DF_UNKNOWN_13 = 13 - DF_UNKNOWN_14 = 14 - DF_UNKNOWN_15 = 15 - DF_LONG_AIR2AIR_SURVEILLANCE_16 = 16 - DF_ADSB_MSG_17 = 17 - DF_EXTENDED_SQUITTER_18 = 18 - DF_MILITARY_EXTENDED_SQUITTER_19 = 19 - DF_COMM_BDS_ALTITUDE_REPLY_20 = 20 - DF_COMM_BDS_IDENTITY_REPLY_21 = 21 - DF_MILITARY_USE_22 = 22 - DF_UNKNOWN_23 = 23 - DF_COMM_D_EXTENDED_LENGTH_MESSAGE_24 = 24 - DF_UNKNOWN_25 = 25 - DF_UNKNOWN_26 = 26 - DF_UNKNOWN_27 = 27 - DF_UNKNOWN_28 = 28 - DF_UNKNOWN_29 = 29 - DF_UNKNOWN_30 = 30 - DF_UNKNOWN_31 = 31 - DF_SSR_MODE_AC_REPLY_32 = 32 + DF_SHORT_AIR2AIR_SURVEILLANCE_0 = "0" + DF_UNKNOWN_1 = "1" + DF_UNKNOWN_2 = "2" + DF_UNKNOWN_3 = "3" + DF_SURVEILLANCE_ALTITUDE_REPLY_4 = "4" + DF_SURVEILLANCE_IDENTITY_REPLY_5 = "5" + DF_UNKNOWN_6 = "6" + DF_UNKNOWN_7 = "7" + DF_UNKNOWN_8 = "8" + DF_UNKNOWN_9 = "9" + DF_UNKNOWN_10 = "10" + DF_ALL_CALL_REPLY_11 = "11" + DF_UNKNOWN_12 = "12" + DF_UNKNOWN_13 = "13" + DF_UNKNOWN_14 = "14" + DF_UNKNOWN_15 = "15" + DF_LONG_AIR2AIR_SURVEILLANCE_16 = "16" + DF_ADSB_MSG_17 = "17" + DF_EXTENDED_SQUITTER_18 = "18" + DF_MILITARY_EXTENDED_SQUITTER_19 = "19" + DF_COMM_BDS_ALTITUDE_REPLY_20 = "20" + DF_COMM_BDS_IDENTITY_REPLY_21 = "21" + DF_MILITARY_USE_22 = "22" + DF_UNKNOWN_23 = "23" + DF_COMM_D_EXTENDED_LENGTH_MESSAGE_24 = "24" + DF_UNKNOWN_25 = "25" + DF_UNKNOWN_26 = "26" + DF_UNKNOWN_27 = "27" + DF_UNKNOWN_28 = "28" + DF_UNKNOWN_29 = "29" + DF_UNKNOWN_30 = "30" + DF_UNKNOWN_31 = "31" + DF_SSR_MODE_AC_REPLY_32 = "32" # Type codes TC_NO_INFO_0 = 0 @@ -170,6 +173,7 @@ class ADSB: cfg_check_phase = config["check phase"] cfg_use_metric = config["use metric"] cfg_apply_bit_err_correction = config["apply bit err correction"] + cfg_run_as_daemon = config["run as daemon"] cfg_read_from_file = config["read from file"] cfg_file_name = config["file name"] cfg_use_text_display = config["use text display"] @@ -181,6 +185,8 @@ class ADSB: cfg_log_file = config["log file"] cfg_log_max_bytes = config["log max bytes"] cfg_log_backup_count = config["log backup count"] + cfg_server_address = config["spots server address"] + cfg_server_port = config["spots server port"] def __init__(self): pass @@ -561,6 +567,7 @@ def __init__(self, interval, func, name): self.interval = interval self.function = func self.finished = threading.Event() + self.daemon = True def run(self): while not self.finished.is_set(): @@ -576,35 +583,52 @@ class Stats: """ Class for collecting some statistics on messages """ - valid_preambles = 0 - valid_crc = 0 - not_valid_crc = 0 - df_0 = 0 - df_4 = 0 - df_5 = 0 - df_16 = 0 - df_17 = 0 - df_18 = 0 - df_20 = 0 - df_21 = 0 + data = {'spots_version': "", + 'start_time': 0, + 'start_time_string': "", + 'valid_preambles': 0, + 'valid_crc': 0, + 'not_valid_crc': 0, + 'df_0': 0, + 'df_4': 0, + 'df_5': 0, + 'df_11': 0, + 'df_16': 0, + 'df_17': 0, + 'df_18': 0, + 'df_20': 0, + 'df_21': 0, + 'df_total': 0 + } def __init__(self): + self['spots_version'] = ADSB.VERSION + self['start_time'] = time.time() + self['start_time_string'] = time.ctime(self['start_time']) pass + def __setitem__(self, key, value): + self.data[key] = value + + def __getitem__(self, item): + return self.data[item] + def __str__(self): st = "\n" - st += "Preambles:{}\n".format(self.valid_preambles) - st += "Valid CRC:{}\n".format(self.valid_crc) - st += "Non valid CRC:{}\n".format(self.not_valid_crc) + st += "Preambles:{}\n".format(self['valid_preambles']) + st += "Valid CRC:{}\n".format(self['valid_crc']) + st += "Non valid CRC:{}\n".format(self['not_valid_crc']) st += "Decoded messages: " - st += "DF0: {} ".format(self.df_0) - st += "DF4: {} ".format(self.df_4) - st += "DF5: {} ".format(self.df_5) - st += "DF16: {} ".format(self.df_16) - st += "DF17: {} ".format(self.df_17) - st += "DF18: {} ".format(self.df_18) - st += "DF20: {} ".format(self.df_20) - st += "DF21: {} ".format(self.df_21) + st += "DF0: {} ".format(self['df_0']) + st += "DF4: {} ".format(self['df_4']) + st += "DF5: {} ".format(self['df_5']) + st += "DF11: {} ".format(self['df_11']) + st += "DF16: {} ".format(self['df_16']) + st += "DF17: {} ".format(self['df_17']) + st += "DF18: {} ".format(self['df_18']) + st += "DF20: {} ".format(self['df_20']) + st += "DF21: {} ".format(self['df_21']) + st += "DF Total: {} ".format(self['df_total']) return st diff --git a/emitter.py b/emitter.py new file mode 100644 index 0000000..2da7cc7 --- /dev/null +++ b/emitter.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python + +from flask import Flask +import socket + + +__author__ = 'Wolfrax' + +app = Flask(__name__) +cfg_server_address = 'localhost' +cfg_server_port = 5051 + + +def get_msg(message): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((cfg_server_address, cfg_server_port)) + data = [] + try: + sock.sendall(message) + while True: + stream = sock.recv(1024) + if not stream: + break + else: + data.append(stream) + data = ''.join(data) + finally: + sock.close() + return data + + +@app.route("/spots/data") +def spots_data(): + return get_msg("GET DATA STR") + + +@app.route("/spots/statistics") +def spots_statistics(): + return get_msg("GET STATISTICS STR") + + +if __name__ == "__main__": + print "Will listen on {}:{}".format(cfg_server_address, cfg_server_port) + app.run(host='0.0.0.0', debug=True) diff --git a/radar.conf b/radar.conf new file mode 100644 index 0000000..c67e972 --- /dev/null +++ b/radar.conf @@ -0,0 +1,9 @@ +[program:radar] +command=/home/pi/.virtualenvs/spots/bin/python /home/pi/app/spots/radar.py +directory=/home/pi/app/spots +autostart=true +autorestart=true +startretries=3 +stderr_logfile=/var/log/supervisor/radar.err +stdout_logfile=/var/log/supervisor/radar.log +user=root \ No newline at end of file diff --git a/radar.py b/radar.py index ae8808f..94e63cc 100644 --- a/radar.py +++ b/radar.py @@ -8,6 +8,9 @@ import logging.handlers import time import sys +import server + +__author__ = 'Wolfrax' """ This module implements the application using 2 classes @@ -24,13 +27,13 @@ message queue (msgQ). The message passed from the tuner is in the format [[signal strength (float), message (long)], [signal strength, message], ...] - + signal strength is a simple measure of (max sample - min sample) in the preamble divided by max range (65535) expressed in %. Thus signal_strength = 10 means that the diff of 'max sample' and 'min sample' is 10% of the dynamic range. - + message is the signal found by the tuner encoded as a long integer. - + The radar object pick up the basic Squitter objects from the message queue (msgQ), decodes it further (using Squitter.py) and store the resulting Squitter object into a 'blip dictionary' using the ICAO information as key. @@ -48,7 +51,7 @@ class TextDisplay: """ This implements a table text display for Squitter messages using curses - + Methods are: add: add a new message + timestamp + count into the msgQ dictionary using ICAO address as key The methods also traverse the msqQ objects to propagate message items to the latest on @@ -56,6 +59,7 @@ class TextDisplay: update_screen: format and add strings to the window form the msgQ close: close the window environment in an orderly manner """ + def __init__(self): self.msgQ = {} @@ -63,7 +67,7 @@ def __init__(self): self.header_text = [[0, "ICAO"], [8, "Mode"], [14, "Sqwk"], [20, "Flight"], [29, "Alt"], [36, "Spd"], [41, "Hdg"], [48, "Lat"], [57, "Long"], [64, "Sig%"], [70, "Msgs"], [77, "Ti "]] self.last_pos = self.header_text[-1][0] + len(self.header_text[-1][1]) - self.header_underline = "-"*self.last_pos + self.header_underline = "-" * self.last_pos self.header_spinner = "|/-\\" self.update_cnt = 0 self.win = None @@ -88,34 +92,24 @@ def update_screen(self): self.win.addstr(0, ind[0], ind[1]) self.win.addch(0, self.last_pos, self.header_spinner[self.update_cnt % 4]) self.win.addstr(1, 0, self.header_underline) + self.win.clrtobot() self.update_cnt += 1 row = 2 for key in self.msgQ.keys(): - for msg in self.msgQ[key]['msg']: - self.win.addstr(row, self.header_text[0][0], msg.ICAO24, curses.color_pair(3)) - self.win.clrtoeol() - if msg.downlink_format != 0: - self.win.addstr(row, self.header_text[1][0], str(msg.downlink_format), curses.color_pair(3)) - if msg.Squawk != 0: - self.win.addstr(row, self.header_text[2][0], "{:=04X}".format(msg.Squawk), curses.color_pair(3)) - if msg.call_sign != "": - self.win.addstr(row, self.header_text[3][0], msg.call_sign, curses.color_pair(3)) - if msg.altitude != 0: - self.win.addstr(row, self.header_text[4][0], str(int(round(msg.altitude))), curses.color_pair(3)) - if msg.velocity != 0: - self.win.addstr(row, self.header_text[5][0], str(int(round(msg.velocity))), curses.color_pair(3)) - if msg.heading != 0: - self.win.addstr(row, self.header_text[6][0], str(int(round(msg.heading))), curses.color_pair(3)) - if msg.latitude != 0: - self.win.addstr(row, self.header_text[7][0], str(round(msg.latitude, 3)), curses.color_pair(3)) - if msg.longitude != 0: - self.win.addstr(row, self.header_text[8][0], str(round(msg.longitude, 3)), curses.color_pair(3)) - if msg.signal_strength != 0: - self.win.addstr(row, self.header_text[9][0], str(msg.signal_strength), curses.color_pair(3)) - - self.win.addstr(row, self.header_text[10][0], str(self.msgQ[key]['msg_count']), curses.color_pair(2)) - self.win.addstr(row, self.header_text[11][0], str(self.msgQ[key]['timestamp']), curses.color_pair(1)) + msg = self.msgQ[key]['msg'] + self.win.addstr(row, self.header_text[0][0], msg['ICAO24'], curses.color_pair(3)) + self.win.addstr(row, self.header_text[1][0], msg['downlink_format'], curses.color_pair(3)) + self.win.addstr(row, self.header_text[2][0], msg['squawk'], curses.color_pair(3)) + self.win.addstr(row, self.header_text[3][0], msg['call_sign'], curses.color_pair(3)) + self.win.addstr(row, self.header_text[4][0], msg['altitude'], curses.color_pair(3)) + self.win.addstr(row, self.header_text[5][0], msg['velocity'], curses.color_pair(3)) + self.win.addstr(row, self.header_text[6][0], msg['heading'], curses.color_pair(3)) + self.win.addstr(row, self.header_text[7][0], msg['latitude'], curses.color_pair(3)) + self.win.addstr(row, self.header_text[8][0], msg['longitude'], curses.color_pair(3)) + self.win.addstr(row, self.header_text[9][0], msg['signal_strength'], curses.color_pair(3)) + self.win.addstr(row, self.header_text[10][0], self.msgQ[key]['msg_count'], curses.color_pair(2)) + self.win.addstr(row, self.header_text[11][0], self.msgQ[key]['timestamp'], curses.color_pair(1)) row += 1 @@ -125,48 +119,19 @@ def clear_queue(self): self.msgQ = {} def add(self, ts, msg, cnt): - # msgQ = {'abc': {'msg': [squitter1, squitter2, ...], 'timestamp': 0, 'msg_count': 1}, 'def': - if msg.ICAO24 in self.msgQ: - self.msgQ[msg.ICAO24]['msg'].append(msg) - self.msgQ[msg.ICAO24]['timestamp'] = str(int(time.time() - ts)) - self.msgQ[msg.ICAO24]['msg_count'] = cnt + # msgQ = {'abc': {'msg': squitter, 'timestamp': '0', 'msg_count': '1'}, 'def':... + icao = msg['ICAO24'] + if icao in self.msgQ: + self.msgQ[icao]['msg'].update(msg) else: - self.msgQ[msg.ICAO24] = {} - self.msgQ[msg.ICAO24]['msg'] = [msg] - self.msgQ[msg.ICAO24]['timestamp'] = str(int(time.time() - ts)) - self.msgQ[msg.ICAO24]['msg_count'] = cnt + self.msgQ[icao] = {} + self.msgQ[icao]['msg'] = msg - # Scan list for call sign and Squawk information and copy this to new list items - # If we don't do this the information will be lost when we remove the item - - for key in self.msgQ.keys(): - call_sign = "" - velocity = altitude = heading = squawk = latitude = longitude = 0 - for item in self.msgQ[key]['msg']: - if item.call_sign != "": - call_sign = item.call_sign - item.call_sign = call_sign - if item.Squawk != 0: - squawk = item.Squawk - item.Squawk = squawk - if item.velocity != 0: - velocity = item.velocity - item.velocity = velocity - if item.altitude != 0: - altitude = item.altitude - item.altitude = altitude - if item.heading != 0: - heading = item.heading - item.heading = heading - if item.latitude != 0: - latitude = item.latitude - item.latitude = latitude - if item.longitude != 0: - longitude = item.longitude - item.longitude = longitude + self.msgQ[icao]['timestamp'] = str(int(time.time() - ts)) + self.msgQ[icao]['msg_count'] = str(cnt) if len(self.msgQ) == self.max_row - 2: - del self.msgQ[msg.ICAO24] + del self.msgQ[icao] def close(self): curses.curs_set(self.saved_cur) @@ -179,13 +144,13 @@ class Radar(basic.ADSB, threading.Thread): It implements a Queue (msgQ) where parsed squitter messages from the tuner are stored for processing. Radar run through its own thread where the main loop (run) gets messages from msQ - + The tuner_read method are a callback used by the tuner, this method is executed from the Tuner thread. - + As messages are processed from the Queue they are stored into a dictionary (blips) using the ICAO address as index. Messages in the dictionary have a timestamp and messages older than MAX_MSG_LIFETIME (seconds) are removed. When messages are refreshed the timestamp are updated. - + The radar use a Text User Interface (TUI) to display information to the end user. """ @@ -202,7 +167,33 @@ def __init__(self): self.daemon = True # This is a daemon thread self.logger = logging.getLogger('spots.Radar') self.blip_timer = basic.RepeatTimer(1, self._scan_blips, "Radar blip timer") + self.stat_timer = basic.RepeatTimer(3600, self._show_stats, "Radar stat timer") self.blip_timer.start() + self.stat_timer.start() + + def _show_stats(self): + self.logger.info(str(basic.statistics)) + + @staticmethod + def get_statistics(): + return basic.statistics.data + + def get_blips_serialized(self): + # blips_series: {{'count': x, 'timestamp': y, 'altitude': 0, 'longitude': 13.755, ...}, + # {'count': y, ...}} + + result = [] + self.lock.acquire() + + for key in self.blips.keys(): + elem = {'count': self.blips[key]['count'], + 'timestamp': str(int(time.time() - self.blips[key]['timestamp']))} + for msg_key in self.blips[key]['msg']: + elem.update({msg_key: self.blips[key]['msg'][msg_key]}) + result.append(elem) + + self.lock.release() + return result def _scan_blips(self): """ @@ -213,11 +204,10 @@ def _scan_blips(self): self._remove_old_blips() for key in self.blips.keys(): - for item in self.blips[key]: - if self.cfg_use_text_display: - self.screen.add(item['timestamp'], item['msg'], item['count']) - else: - print item['msg'] + if self.cfg_use_text_display: + self.screen.add(self.blips[key]['timestamp'], self.blips[key]['msg'], self.blips[key]['count']) + else: + print self.blips[key]['msg'] self.lock.release() if self.cfg_use_text_display: @@ -229,61 +219,45 @@ def _remove_old_blips(self): Remove blips that has a timestamp older than specified level (normally 60 secs) """ for key in self.blips.keys(): - ind = 0 - for item in self.blips[key]: - if (time.time() - item['timestamp']) >= self.cfg_max_blip_ttl: - del self.blips[key][ind] - ind += 1 - if not self.blips[key]: + if (time.time() - self.blips[key]['timestamp']) >= self.cfg_max_blip_ttl: del self.blips[key] def _blip_add(self, msg): """ Decdode and add msg to the blips dictionary with a timestamp using ICAO address as key. - If an entry on the ICAO address exists, append the element. If it does not exists, create a new list. - + If an entry on the ICAO address exists, update the element. + Note that a lock is needed before the blip dictionary is modified to avoid confusing the reader thread """ msg.decode() + icao = msg['ICAO24'] self.lock.acquire() - # blip: {ICAO24: [{'timestamp': ts, 'count': n, 'msg': msg}, {'timestamp: ts, ...}]} - if msg.ICAO24 in self.blips: - item = {'timestamp': time.time(), 'count': self.blips[msg.ICAO24][-1]['count'] + 1, 'msg': msg} - self.blips[msg.ICAO24].append(item) + # blip: {ICAO24: {'timestamp': ts, 'count': n, 'msg': msg},...} + if icao in self.blips: + self.blips[icao]['msg'].update(msg) + self.blips[icao]['timestamp'] = time.time() + self.blips[icao]['count'] += 1 else: - item = {'timestamp': time.time(), 'count': 1, 'msg': msg} - self.blips[msg.ICAO24] = [item] + self.blips[icao] = {'msg': msg, 'timestamp': time.time(), 'count': 1} - # Decode latitude and longitude by finding 2 frames, 1 with odd CPR format + 1 with even CPR format - for key in self.blips.keys(): - odd_msg = even_msg = None - for item in self.blips[key]: - if item['msg'].is_CPR(): - if item['msg'].is_odd_CPR(): - odd_msg = item['msg'] - else: - even_msg = item['msg'] - if (even_msg is not None) and (odd_msg is not None): - result = squitter.decodeCPR(odd_msg, even_msg) - if result is not None: - item['msg'].add_lat_long(result['latitude'], result['longitude']) - else: - result = squitter.decodeCPR_relative(odd_msg, even_msg) - if result is not None: - item['msg'].add_lat_long(result['latitude'], result['longitude']) + # Note, we need to decode for latitude/longitude after we have updated the message as the algorithm + # is dependent on 2 frames; odd and even + if not self.blips[icao]['msg'].decodeCPR(): + pass # decodeCPR_relative is not validated yet and will likely yield wrong values + # self.blips[icao]['msg'].decodeCPR_relative() self.lock.release() if self.cfg_verbose_logging: self.logger.info("{}".format(str(msg))) def _blip_exist(self, msg): - return msg.ICAO24 in self.blips + return msg['ICAO24'] in self.blips def run(self): """ Main loop for the Radar object, here is were objects from the tuner is retrieved and checked before adding to the blip dictionary. - + Note that the crc is checked here versus different type of messages. Messages are added to the blip dictionary if: 1. The crc is ok @@ -299,22 +273,23 @@ def run(self): while not self.finished.is_set(): msg = self.msgQ.get() - if msg.downlink_format == self.DF_ALL_CALL_REPLY_11: + if msg.get_downlink_format() == self.DF_ALL_CALL_REPLY_11: if msg.crc_ok: self._blip_add(msg) else: if basic.ADSB.crc_2_int(msg.crc_sum) < 80 and self._blip_exist(msg): msg.crc_ok = True self._blip_add(msg) - elif (msg.downlink_format == self.DF_ADSB_MSG_17 or msg.downlink_format == self.DF_EXTENDED_SQUITTER_18) \ - and msg.crc_ok: + elif msg.get_downlink_format() == self.DF_ADSB_MSG_17 and msg.crc_ok: + self._blip_add(msg) + elif msg.get_downlink_format() == self.DF_EXTENDED_SQUITTER_18 and msg.crc_ok: self._blip_add(msg) else: # All other DF have CRC xor'ed with ICAO address if msg.crc_ok: self._blip_add(msg) else: # we use the crc_sum for ICAO, this is tested in the decode method of Squitter - msg.ICAO24 = msg.crc_sum + msg['ICAO24'] = msg.crc_sum msg.crc_ok = True if self._blip_exist(msg) else False if msg.crc_ok: self._blip_add(msg) @@ -326,6 +301,7 @@ def _die(self): self.finished.set() self.blip_timer.cancel() + self.stat_timer.cancel() if self.cfg_use_text_display: self.screen.close() @@ -344,7 +320,7 @@ def tuner_read(self, msgs, stop=False): self.msgQ.put(sq) -def main(): +def run_Radar(): print "Spots version {}".format(basic.ADSB.VERSION) sys.stderr = open("spots.err", 'w') @@ -372,8 +348,18 @@ def main(): radar = Radar() radar.start() + host = basic.ADSB.cfg_server_address + port = basic.ADSB.cfg_server_port + + spots_server = server.SpotsServer((host, port), radar) + spots_server.start() + + logger.info("Spots message server running, listening on {}:{}".format(host, port)) + tuner1090.read(radar.tuner_read) # This is the main loop and main thread + spots_server.die() + if __name__ == '__main__': - main() + run_Radar() diff --git a/server.py b/server.py new file mode 100644 index 0000000..aac887a --- /dev/null +++ b/server.py @@ -0,0 +1,59 @@ +import json +import SocketServer +import socket +import logging +import threading + +__author__ = 'Wolfrax' + +""" +This implements server functionality for spots, enabling clients to access spots data over network. + +The Spots server implements a threaded server, one thread per request. The requests follow a simple protocol + "GET DATA STR": message from the client will return the radar blip messages in serialized/json format + "GET STATISTICS STR": message from the client will return spots statistics in serialized/json format +""" + + +class TCPRequestHandler(SocketServer.BaseRequestHandler): + def handle(self): + cmd = self.request.recv(1024) + + if cmd == "GET DATA STR": + response = self.server.radar.get_blips_serialized() + elif cmd == "GET STATISTICS STR": + response = self.server.radar.get_statistics() + else: + return + + self.request.sendall(json.dumps(response)) + + +class SpotsServer(SocketServer.ThreadingMixIn, SocketServer.TCPServer): + """ + A threaded TCP server handling requests through the TCPRequestHandler. + It will start a new thread for each request, these are managed by the method handle + """ + def __init__(self, server_address, radar_object): + SocketServer.TCPServer.allow_reuse_address = True + + SocketServer.TCPServer.__init__(self, server_address, TCPRequestHandler) + + # reuse a local socket in TIME_WAIT state (SO_REUSEADDR) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + self.server_thread = threading.Thread(target=self.serve_forever) + self.server_thread.daemon = True + self.server_thread.name = "Socket server" + + self.radar = radar_object + self.logger = logging.getLogger('spots.Server') + self.logger.info("Message server initialized") + + def start(self): + self.server_thread.start() + + def die(self): + self.shutdown() + self.server_close() + diff --git a/setup.py b/setup.py index d173790..ee606a8 100644 --- a/setup.py +++ b/setup.py @@ -14,5 +14,5 @@ license='GPL', author='Mats Melander', author_email='mats.melander@gmail.com', - description='A decoder for ADS-B messages on extended squitter at 1090MHz', requires=['numpy'] + description='A decoder for ADS-B messages on extended squitter at 1090MHz', requires=['numpy', 'flask'] ) diff --git a/spots.html b/spots.html new file mode 100644 index 0000000..cc54c36 --- /dev/null +++ b/spots.html @@ -0,0 +1,176 @@ + + + + + Spots + + + + + + + + + + + + + + + + + +
+
+
+

Spots at Viltstigen 3, Lund

+

+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
ICAOModeSquawkFlightAltitudeSpeedHeadingLatitudeLongitudeSignal (%)MessagesTime
+
+
+ +
+
+

Statistics

+

+
+
+ +
+
+
+
+
+
+ + + +
+ + diff --git a/spots.js b/spots.js new file mode 100644 index 0000000..bc3247e --- /dev/null +++ b/spots.js @@ -0,0 +1,142 @@ +/** + * Created by mm on 2017-05-30. + */ + +function Plotter() { + this.statistics = function (stats) { + new Highcharts.Chart({ + chart: { + renderTo: graphStatistics, + type: 'column' + }, + title: { + text: 'Spots statistics' + }, + xAxis: { + categories: [ + 'DF Total', + 'DF 0', + 'DF 4', + 'DF 5', + 'DF 11', + 'DF 16', + 'DF 17', + 'DF 18', + 'DF 20', + 'DF 21' + ], + crosshair: true + }, + yAxis: { + min: 0, + title: { + text: '#' + } + }, + tooltip: { + headerFormat: '{point.key}', + pointFormat: '' + + '', + footerFormat: '
{series.name}: {point.y:.0f}
', + shared: true, + useHTML: true + }, + plotOptions: { + column: { + pointPadding: 0.2, + borderWidth: 0 + } + }, + series: [{ + name: 'Downlink format', + data: stats + + }] + }) + }; + + var gaugeOptions = { + chart: { + type: 'solidgauge' + }, + + title: null, + + pane: { + center: ['50%', '85%'], + size: '100%', + startAngle: -90, + endAngle: 90, + background: { + backgroundColor: (Highcharts.theme && Highcharts.theme.background2) || '#EEE', + innerRadius: '60%', + outerRadius: '100%', + shape: 'arc' + } + }, + + tooltip: { + enabled: false + }, + + // the value axis + yAxis: { + stops: [ + [0.1, '#55BF3B'], // green + [0.5, '#DDDF0D'], // yellow + [0.9, '#DF5353'] // red + ], + lineWidth: 0, + minorTickInterval: null, + tickAmount: 2, + title: { + y: -70 + }, + labels: { + y: 16 + } + }, + + plotOptions: { + solidgauge: { + dataLabels: { + y: 5, + borderWidth: 0, + useHTML: true + } + } + } + }; + + var chartSpeed = Highcharts.chart(Highcharts.merge(gaugeOptions, { + chart: { + renderTo: graphPreamble + }, + yAxis: { + min: 0, + max: 100, + title: { + text: 'Preamble/sec' + } + }, + credits: { + enabled: false + }, + series: [{ + name: 'Preamble/sec', + data: [0], + dataLabels: { + format: '
{y}
' + + 'preamble/sec
' + }, + tooltip: { + valueSuffix: ' preamble/sec' + } + }] + })); + + this.preamble = function (val) { + chartSpeed.series[0].points[0].update(val); + }; +} \ No newline at end of file diff --git a/spots.png b/spots.png new file mode 100644 index 0000000..205ce84 Binary files /dev/null and b/spots.png differ diff --git a/spots_config.json b/spots_config.json index 0d7d458..c091433 100644 --- a/spots_config.json +++ b/spots_config.json @@ -4,13 +4,16 @@ "check phase": false, "use metric": true, "apply bit err correction": false, + "run as daemon": true, "read from file": false, "file name": "modes1.bin", - "use text display": true, + "use text display": false, "max blip ttl": 60.0, - "user latitude": 0.0, - "user longitude": 0.0, + "user latitude": 55.732727, + "user longitude": 13.172479, "log file": "spots.log", "log max bytes": 1048576, - "log backup count": 10 + "log backup count": 10, + "spots server address": "localhost", + "spots server port": 5051 } \ No newline at end of file diff --git a/spots_emitter.conf b/spots_emitter.conf new file mode 100644 index 0000000..3af660c --- /dev/null +++ b/spots_emitter.conf @@ -0,0 +1,9 @@ +[program:spots_emitter] +command = /home/pi/.virtualenvs/spots/bin/python /home/pi/.virtualenvs/spots/bin/gunicorn -b :8080 --reload emitter:app +directory = /home/pi/app/spots +user = root +autostart = true +autorestart = true +startretries=3 +stdout_logfile = /var/log/supervisor/spots_emitter.log +stderr_logfile = /var/log/supervisor/spots_emitter.err \ No newline at end of file diff --git a/squitter.json b/squitter.json index 6c684c9..52106f7 100644 --- a/squitter.json +++ b/squitter.json @@ -1,5 +1,5 @@ { - "type code": [ + "type_code": [ "0: No position information", "1: Identification (Category D)", "2: Identification (Category C)", @@ -33,7 +33,7 @@ "30: No longer used", "31: Aircraft Operational Status" ], - "downlink format": [ + "downlink_format": [ "0: Short Air-Air Surveillance", "1: Unknown DF", "2: Unknown DF", @@ -68,7 +68,7 @@ "31: Unknown DF", "32: SSR : Mode A/C Reply" ], - "flight status": [ + "flight_status": [ "Normal, Airborne", "Normal, On the ground", "ALERT, Airborne", diff --git a/squitter.py b/squitter.py index 9f41ce2..54160d0 100644 --- a/squitter.py +++ b/squitter.py @@ -1,5 +1,8 @@ import basic import math +import logging + +__author__ = 'Wolfrax' """ Implements the Squitter class which represents decoded messages. @@ -180,182 +183,218 @@ def CPR_NL(lat): return basic.ADSB.NL.index(max(filter(lambda x: x < lat, basic.ADSB.NL))) + 1 -def is_CPR(msg): - return msg.is_CPR_msg - - -def is_odd_CPR(msg): - return msg.odd - - -def decodeCPR(odd_msg, even_msg): - # Basic algorithm: http://www.lll.lu/~edward/edward/adsb/DecodingADSBposition.html - air_dlat_0 = 360 / 60.0 - air_dlat_1 = 360 / 59.0 - lat0 = even_msg.raw_latitude - lat1 = odd_msg.raw_latitude - lon0 = even_msg.raw_longitude - lon1 = odd_msg.raw_longitude - - j = int(math.floor(((59 * lat0 - 60 * lat1) / 131072.0) + 0.5)) - - rlat0 = air_dlat_0 * (j % 60 + lat0 / 131072.0) - rlat1 = air_dlat_1 * (j % 59 + lat1 / 131072.0) - - # Adjust if we are on southern hemisphere by substracting 360 from latitude - if rlat0 >= 270: - rlat0 -= 360 - if rlat1 >= 270: - rlat1 -= 360 - - if rlat0 < -90 or rlat0 > 90 or rlat1 < -90 or rlat1 > 90: - return None - if CPR_NL(rlat0) != CPR_NL(rlat1): - return None - - ni = max(CPR_NL(rlat1) - 1, 1) - m = int(math.floor((((lon0 * (CPR_NL(rlat1) - 1)) - (lon1 * CPR_NL(rlat1))) / 131072.0) + 0.5)) - longitude = (360.0 / ni) * ((m % ni) + lon1 / 131072.0) - latitude = rlat1 - - if longitude > 180: - longitude -= 360 - - return {'latitude': latitude, 'longitude': longitude} - - -def decodeCPR_relative(odd_msg, even_msg): - air_dlat_1 = 360 / 59.0 - - if odd_msg.latitude != 0: - latr = odd_msg.latitude - elif even_msg.latitude != 0: - latr = even_msg.latitude - else: - latr = odd_msg.cfg_latitude - - if odd_msg.longitude != 0: - longr = odd_msg.longitude - elif even_msg.latitude != 0: - longr = even_msg.longitude - else: - longr = odd_msg.longitude - - tmp1 = math.floor(latr / air_dlat_1) - tmp2 = (int(latr) % int(air_dlat_1)) - j = int(tmp1 + math.trunc(0.5 + tmp2 / air_dlat_1 - odd_msg.raw_latitude / 131072.0)) - rlat = air_dlat_1 * (j + odd_msg.raw_latitude / 131072.0) - if rlat >= 270: - rlat -= 360 - - if rlat < -90 or rlat > 90: - return None - - if abs(rlat - latr) > (air_dlat_1 / 2): - return None - - air_dlon = 360 / max(CPR_NL(rlat) - 1, 1) - m = int(math.floor(longr / air_dlon) - + math.trunc(0.5 + (int(longr) % int(air_dlon)) / air_dlon - odd_msg.raw_longitude / 131072.0)) - rlon = air_dlon * (m + odd_msg.raw_longitude / 131072.0) - if rlon > 180: - rlon -= 360 - - return {'latitude': rlat, 'longitude': rlon} - - class Squitter(basic.ADSB): def __init__(self): basic.ADSB.__init__(self) + self.data = {'signal_strength': "", + 'downlink_format': "", + 'ICAO24': "", + 'squawk': "", + 'altitude': "", + 'call_sign': "", + 'velocity': "", + 'heading': "", + 'latitude': "", + 'longitude': ""} + # This is the Squitter object definition and initialization - self.signal_strength = 0 self.msg = 0 self.no_of_bits = 0 - self.downlink_format = 0 self.capability = 0 - self.ICAO24 = 0 self.type_code = 0 self.emitter_category = 0 self.parity = 0 self.crc_sum = "0" self.crc_ok = False - self.Squawk = 0 - self.altitude = 0 - self.call_sign = "" self.vertical_rate = 0 self.ew_velocity = 0 self.ns_velocity = 0 - self.velocity = 0 - self.heading = 0 self.flight_status = 0 - self.latitude = 0 - self.longitude = 0 - self.raw_latitude = 0 - self.raw_longitude = 0 + self.odd_raw_latitude = 0 + self.odd_raw_longitude = 0 + self.even_raw_latitude = 0 + self.even_raw_longitude = 0 + self.odd_pos = False + self.even_pos = False self.on_ground = False - self.odd = False - self.is_CPR_msg = False + + self.logger = logging.getLogger('spots.squitter') + + def __setitem__(self, key, value): + self.data[key] = value + + def __getitem__(self, item): + return self.data[item] + + def __iter__(self): + for key in self.data: + yield key def __str__(self): st = "" st += "* {}\n".format(hex(self.msg)[2:-1]) st += "{} ".format(hex(self.parity)[2:-1]) st += "CRC: {} ({}) ".format(self.crc_sum, "ok" if self.crc_ok else "not ok") - st += "ICAO: {} ".format(self.ICAO24) - st += "DF - {} ".format(self.squitter["downlink format"][self.downlink_format]) - st += "TC - {} ".format(self.squitter["type code"][self.type_code]) - - if self.altitude > 0: - st += "{}ft ".format(self.altitude) - if self.call_sign != "": - st += "{} ".format(self.call_sign) - if self.Squawk != 0: - st += "Squawk: {:=04X} ".format(self.Squawk) - if self.longitude != 0: - st += "long: {0:.3f} ".format(self.longitude) - if self.latitude != 0: - st += "lat: {0:.3f} ".format(self.latitude) + st += "ICAO: {} ".format(self['ICAO24']) + st += "DF - {} ".format(self.squitter['downlink_format'][int(self['downlink_format'])]) + st += "TC - {} ".format(self.squitter['type_code'][self.type_code]) + + if self['altitude'] != "": + st += "{}{} ".format(self['altitude'], "m" if self.cfg_use_metric else "ft") + if self['call_sign'] != "": + st += "{} ".format(self['call_sign']) + if self['squawk'] != "": + st += "squawk: {} ".format(self['squawk']) + if self['longitude'] != "": + st += "long: {} ".format(self['longitude']) + if self['latitude'] != "": + st += "lat: {} ".format(self['latitude']) if self.vertical_rate != 0: st += "vrate: {} ".format(self.vertical_rate) - if self.velocity != 0: - st += "vel: {} ".format(self.velocity) - if self.heading != 0: - st += "head: {} ".format(self.heading) + if self['velocity'] != "": + st += "vel: {} ".format(self['velocity']) + if self['heading'] != "": + st += "head: {} ".format(self['heading']) if self.flight_status != 0: - st += "fs: {} {} ".format(self.flight_status, self.squitter["flight status"][self.flight_status]) - if self.signal_strength != 0: - st += "sig: {}% ".format(self.signal_strength) + st += "fs: {} {} ".format(self.flight_status, self.squitter["flight_status"][self.flight_status]) + if self['signal_strength'] != "": + st += "sig: {}% ".format(self['signal_strength']) return st - def is_odd_CPR(self): - return self.odd + def update(self, msg): + self.data['signal_strength'] = msg['signal_strength'] + self.data['downlink_format'] = msg['downlink_format'] + + self.data['squawk'] = msg['squawk'] if msg['squawk'] != "" else self.data['squawk'] + self.data['altitude'] = msg['altitude'] if msg['altitude'] != "" else self.data['altitude'] + self.data['call_sign'] = msg['call_sign'] if msg['call_sign'] != "" else self.data['call_sign'] + self.data['velocity'] = msg['velocity'] if msg['velocity'] != "" else self.data['velocity'] + self.data['heading'] = msg['heading'] if msg['heading'] != "" else self.data['heading'] + self.data['latitude'] = msg['latitude'] if msg['latitude'] != "" else self.data['latitude'] + self.data['longitude'] = msg['longitude'] if msg['longitude'] != "" else self.data['longitude'] + + self.odd_raw_latitude = msg.odd_raw_latitude if msg.odd_raw_latitude != 0 else self.odd_raw_latitude + self.odd_raw_longitude = msg.odd_raw_longitude if msg.odd_raw_longitude != 0 else self.odd_raw_longitude - def is_CPR(self): - return self.is_CPR_msg + self.even_raw_latitude = msg.even_raw_latitude if msg.even_raw_latitude != 0 else self.even_raw_latitude + self.even_raw_longitude = msg.even_raw_longitude if msg.even_raw_longitude != 0 else self.even_raw_longitude - def add_lat_long(self, latitude, longitude): - self.latitude = latitude - self.longitude = longitude + self.odd_pos = msg.odd_pos if msg.odd_pos else self.odd_pos + self.even_pos = msg.even_pos if msg.even_pos else self.even_pos + + def get_downlink_format(self): + return self['downlink_format'] def _get_msg_byte(self, byte_nr): return (self.msg >> (self.no_of_bits - (byte_nr + 1) * 8)) & 0xFF + def decodeCPR(self): + # Basic algorithm: http://www.lll.lu/~edward/edward/adsb/DecodingADSBposition.html + + if self.odd_pos is False or self.even_pos is False: + return False + + air_dlat_0 = 360 / 60.0 + air_dlat_1 = 360 / 59.0 + lat0 = self.even_raw_latitude + lat1 = self.odd_raw_latitude + lon0 = self.even_raw_longitude + lon1 = self.odd_raw_longitude + + j = int(math.floor(((59 * lat0 - 60 * lat1) / 131072.0) + 0.5)) + + rlat0 = air_dlat_0 * (j % 60 + lat0 / 131072.0) + rlat1 = air_dlat_1 * (j % 59 + lat1 / 131072.0) + + # Adjust if we are on southern hemisphere by substracting 360 from latitude + if rlat0 >= 270: + rlat0 -= 360 + if rlat1 >= 270: + rlat1 -= 360 + + if rlat0 < -90 or rlat0 > 90 or rlat1 < -90 or rlat1 > 90: + return False + if CPR_NL(rlat0) != CPR_NL(rlat1): + return False + + ni = max(CPR_NL(rlat1) - 1, 1) + m = int(math.floor((((lon0 * (CPR_NL(rlat1) - 1)) - (lon1 * CPR_NL(rlat1))) / 131072.0) + 0.5)) + longitude = (360.0 / ni) * ((m % ni) + lon1 / 131072.0) + latitude = rlat1 + + if longitude > 180: + longitude -= 360 + + self.data['latitude'] = str(round(latitude, 3)) if latitude != 0.0 else "" + self.data['longitude'] = str(round(longitude, 3)) if longitude != 0.0 else "" + self.even_pos = False + self.odd_pos = False + + return True + + def decodeCPR_relative(self): + if self.odd_pos is False or self.even_pos is False: + return False + + air_dlat_1 = 360 / 59.0 + + if self.odd_raw_latitude != 0: + latr = self.odd_raw_latitude + elif self.even_raw_latitude != 0: + latr = self.even_raw_latitude + else: + latr = self.cfg_latitude + + if self.odd_raw_longitude != 0: + longr = self.odd_raw_longitude + elif self.even_raw_longitude != 0: + longr = self.even_raw_longitude + else: + longr = self.cfg_longitude + + tmp1 = math.floor(latr / air_dlat_1) + tmp2 = (int(latr) % int(air_dlat_1)) + j = int(tmp1 + math.trunc(0.5 + tmp2 / air_dlat_1 - self.odd_raw_latitude / 131072.0)) + rlat = air_dlat_1 * (j + self.odd_raw_latitude / 131072.0) + if rlat >= 270: + rlat -= 360 + + if rlat < -90 or rlat > 90: + return + + if abs(rlat - latr) > (air_dlat_1 / 2): + return + + air_dlon = 360 / max(CPR_NL(rlat) - 1, 1) + m = int(math.floor(longr / air_dlon) + + math.trunc(0.5 + (int(longr) % int(air_dlon)) / air_dlon - self.odd_raw_longitude / 131072.0)) + rlon = air_dlon * (m + self.odd_raw_longitude / 131072.0) + if rlon > 180: + rlon -= 360 + + self.data['latitude'] = str(round(rlat, 3)) if rlat != 0.0 else "" + self.data['longitude'] = str(round(rlon, 3)) if rlon != 0.0 else "" + self.even_pos = False + self.odd_pos = False + + return + def parse(self, obj): """ Parse the message into the object """ # The object consists of 2 parts: [signal_strength, msg] - self.signal_strength = obj[0] + self['signal_strength'] = str(obj[0]) msg = obj[1] # Top 5 bits is DF - self.downlink_format = (int(hex(msg)[2:4], base=16) & 0xF8) >> 3 + self['downlink_format'] = str((int(hex(msg)[2:4], base=16) & 0xF8) >> 3) # Most significant bit indicates length - if self.downlink_format & 0x10: + if int(self['downlink_format']) & 0x10: self.msg = msg self.no_of_bits = self.MODES_LONG_MSG_BITS else: @@ -369,12 +408,12 @@ def parse(self, obj): self.crc_sum = self.crc(hex(self.msg)) # crc_sum is computed on a hexidecimal string self.crc_ok = basic.ADSB.crc_2_int(self.crc_sum) == 0 if self.crc_ok: - basic.statistics.valid_crc += 1 + basic.statistics['valid_crc'] += 1 else: - basic.statistics.not_valid_crc += 1 + basic.statistics['not_valid_crc'] += 1 else: # Skip crc check, discouraged - self.crc_sum = 0 + self.crc_sum = "0" self.crc_ok = True if not self.crc_ok and self.cfg_apply_bit_err_correction: # Apply bit error correction @@ -383,9 +422,9 @@ def parse(self, obj): self.crc_ok = True self.msg = corrected_msg if self.crc_ok: - basic.statistics.valid_crc += 1 + basic.statistics['valid_crc'] += 1 else: - basic.statistics.not_valid_crc += 1 + basic.statistics['not_valid_crc'] += 1 def _get_vertical_rate(self): vertical_rate = ((self._get_msg_byte(8) & 0x07) << 6) | (self._get_msg_byte(9) >> 2) @@ -394,7 +433,7 @@ def _get_vertical_rate(self): if self._get_msg_byte(8) & 0x08: vertical_rate = 0 - vertical_rate vertical_rate = vertical_rate * 64 - return self.METER_PER_FOOT * vertical_rate if self.cfg_use_metric else vertical_rate + return int(round(self.METER_PER_FOOT * vertical_rate)) if self.cfg_use_metric else vertical_rate else: return 0 @@ -402,7 +441,7 @@ def _get_altitude(self): ac_12 = ((self._get_msg_byte(5) << 4) | (self._get_msg_byte(6) >> 4)) & 0x0FFF if ac_12 != 0: altitude = parse_ac12(ac_12) - return self.METER_PER_FOOT * altitude if self.cfg_use_metric else altitude + return int(round(self.METER_PER_FOOT * altitude)) if self.cfg_use_metric else altitude else: return 0 @@ -410,7 +449,7 @@ def _get_velocity(self): movement = ((self._get_msg_byte(4) << 4) | (self._get_msg_byte(5) >> 4)) & 0x007F if 0 < movement < 125: velocity = parse_movement(movement) - return self.KPH_PER_KNOT * velocity if self.cfg_use_metric else velocity + return int(round(self.KPH_PER_KNOT * velocity)) if self.cfg_use_metric else velocity else: return 0 @@ -431,7 +470,7 @@ def decode_ADSB_msg(self): sub_type = self._get_msg_byte(4) & 0x07 if self.TC_ID_CAT_D_1 <= self.type_code <= self.TC_ID_CAT_A_4: - self.call_sign = callsign(hex(self.msg)[2:-1]) + self['call_sign'] = callsign(hex(self.msg)[2:-1]) elif self.type_code == self.TC_AIRBORNE_VELOCITY_19: if 1 <= sub_type <= 4: self.vertical_rate = self._get_vertical_rate() @@ -456,56 +495,65 @@ def decode_ADSB_msg(self): self.ns_velocity = ns_velocity if east_west_raw != 0 and north_south_raw != 0: - self.velocity = int(round(math.sqrt((ns_velocity ** 2) + (ew_velocity ** 2)))) + velocity = math.sqrt((ns_velocity ** 2) + (ew_velocity ** 2)) if self.cfg_use_metric: - self.velocity = self.KPH_PER_KNOT * self.velocity - if self.velocity != 0: - self.heading = int(round(math.atan2(ew_velocity, ns_velocity) * 180 / math.pi)) - if self.heading < 0: - self.heading += 360 + self['velocity'] = str(int(round(self.KPH_PER_KNOT * velocity))) + else: + self['velocity'] = str(int(round(velocity))) + if velocity != 0: + heading = math.atan2(ew_velocity, ns_velocity) * 180 / math.pi + if heading < 0: + heading += 360 + self['heading'] = str(int(round(heading))) if 3 <= sub_type <= 4: airspeed = ((self._get_msg_byte(7) & 0x7f) << 3) | (self._get_msg_byte(8) >> 5) if airspeed != 0: airspeed -= 1 if sub_type == 4: # supersonic airspeed = airspeed << 2 - self.velocity = airspeed + self['velocity'] = str(airspeed) if self._get_msg_byte(5) & 0x04: - self.heading = ((((self._get_msg_byte(5) & 0x03) << 8) | self._get_msg_byte(6)) * 45) >> 7 + self['heading'] = str(((((self._get_msg_byte(5) & 0x03) << 8) | self._get_msg_byte(6)) * 45) >> 7) elif self.TC_SURFACE_POS_5 <= self.type_code <= self.TC_AIRBORNE_POS_22: - self.is_CPR_msg = True - self.odd = True if self._get_msg_byte(6) & 0x04 else False + odd = True if (self._get_msg_byte(6) & 0x04) else False + + if odd: + self.odd_pos = True + else: + self.even_pos = True - self.raw_latitude = ((self._get_msg_byte(6) & 0x03) << 15) \ - | (self._get_msg_byte(7) << 7) \ - | (self._get_msg_byte(8) >> 1) - self.raw_longitude = ((self._get_msg_byte(8) & 0x01) << 16) \ - | (self._get_msg_byte(9) << 8) \ - | (self._get_msg_byte(10)) + lat = ((self._get_msg_byte(6) & 0x03) << 15) | (self._get_msg_byte(7) << 7) | (self._get_msg_byte(8) >> 1) + lon = ((self._get_msg_byte(8) & 0x01) << 16) | (self._get_msg_byte(9) << 8) | (self._get_msg_byte(10)) + if odd: + self.odd_raw_latitude = lat + self.odd_raw_longitude = lon + else: + self.even_raw_latitude = lat + self.even_raw_longitude = lon if self.TC_AIRBORNE_POS_9 <= self.type_code <= self.TC_AIRBORNE_POS_18: - self.altitude = self._get_altitude() + self['altitude'] = str(self._get_altitude()) self.on_ground = False elif self.TC_AIRBORNE_POS_20 <= self.type_code <= self.TC_AIRBORNE_POS_22: - self.altitude = self._get_altitude() + self['altitude'] = str(self._get_altitude()) self.on_ground = False elif self.TC_SURFACE_POS_5 <= self.type_code <= self.TC_SURFACE_POS_8: - self.velocity = self._get_velocity() - self.heading = self._get_heading() + self['velocity'] = str(self._get_velocity()) + self['heading'] = str(self._get_heading()) self.on_ground = True elif self.type_code == self.TC_RESERVED_TEST_23: if sub_type == 7: id_13 = (((self._get_msg_byte(5) << 8) | self._get_msg_byte(6)) & 0xFFF1) >> 3 if id_13 != 0: - self.Squawk = parse_id13(id_13) + self['squawk'] = str("{:=04X}".format(parse_id13(id_13))) elif self.type_code == self.TC_EXT_SQ_AIRCRFT_STATUS_28: if sub_type == 1: id_13 = ((self._get_msg_byte(5) << 8) | self._get_msg_byte(6)) & 0x1FFF if id_13 != 0: - self.Squawk = parse_id13(id_13) + self['squawk'] = str("{:=04X}".format(parse_id13(id_13))) def decode_extended_squitter_msg(self): if self.capability == 0 or self.capability == 1 or self.capability == 6: @@ -513,73 +561,82 @@ def decode_extended_squitter_msg(self): def decode_comm_bds_reply_msg(self): if self._get_msg_byte(4) == 0x20: - self.call_sign = callsign(hex(self.msg)[2:-1]) + self['call_sign'] = callsign(hex(self.msg)[2:-1]) def decode_altitude_msg(self): ac_13 = ((self._get_msg_byte(2) << 8) | self._get_msg_byte(3)) & 0x1FFF if ac_13 != 0: altitude = parse_ac13(ac_13) - self.altitude = self.METER_PER_FOOT * altitude if self.cfg_use_metric else altitude + self['altitude'] = str(int(round(self.METER_PER_FOOT * altitude))) if self.cfg_use_metric else str(altitude) def decode_identity_msg(self): if self.TC_ID_CAT_D_1 <= self.type_code <= self.TC_ID_CAT_A_4: - self.Squawk = self._get_identity() + self['squawk'] = str("{:=04X}".format(self._get_identity())) def decode_comm_bds_identity_msg(self): - self.Squawk = self._get_identity() + self['squawk'] = str("{:=04X}".format(self._get_identity())) def decode_flight_status_msg(self): self.flight_status = self._get_msg_byte(0) & 0x07 + def decode_all_reply_msg(self): + pass # Nothing to decode + def _update_statistics(self): - if self.downlink_format == self.DF_SHORT_AIR2AIR_SURVEILLANCE_0: - basic.statistics.df_0 += 1 - elif self.downlink_format == self.DF_SURVEILLANCE_ALTITUDE_REPLY_4: - basic.statistics.df_4 += 1 - elif self.downlink_format == self.DF_SURVEILLANCE_IDENTITY_REPLY_5: - basic.statistics.df_5 += 1 - elif self.downlink_format == self.DF_LONG_AIR2AIR_SURVEILLANCE_16: - basic.statistics.df_16 += 1 - elif self.downlink_format == self.DF_ADSB_MSG_17: - basic.statistics.df_17 += 1 - elif self.downlink_format == self.DF_EXTENDED_SQUITTER_18: - basic.statistics.df_18 += 1 - elif self.downlink_format == self.DF_COMM_BDS_ALTITUDE_REPLY_20: - basic.statistics.df_20 += 1 - elif self.downlink_format == self.DF_COMM_BDS_IDENTITY_REPLY_21: - basic.statistics.df_21 += 1 + if self['downlink_format'] == self.DF_SHORT_AIR2AIR_SURVEILLANCE_0: + basic.statistics['df_0'] += 1 + elif self['downlink_format'] == self.DF_SURVEILLANCE_ALTITUDE_REPLY_4: + basic.statistics['df_4'] += 1 + elif self['downlink_format'] == self.DF_SURVEILLANCE_IDENTITY_REPLY_5: + basic.statistics['df_5'] += 1 + elif self['downlink_format'] == self.DF_ALL_CALL_REPLY_11: + basic.statistics['df_11'] += 1 + elif self['downlink_format'] == self.DF_LONG_AIR2AIR_SURVEILLANCE_16: + basic.statistics['df_16'] += 1 + elif self['downlink_format'] == self.DF_ADSB_MSG_17: + basic.statistics['df_17'] += 1 + elif self['downlink_format'] == self.DF_EXTENDED_SQUITTER_18: + basic.statistics['df_18'] += 1 + elif self['downlink_format'] == self.DF_COMM_BDS_ALTITUDE_REPLY_20: + basic.statistics['df_20'] += 1 + elif self['downlink_format'] == self.DF_COMM_BDS_IDENTITY_REPLY_21: + basic.statistics['df_21'] += 1 + basic.statistics['df_total'] += 1 def decode(self): - if self.ICAO24 == 0: # if ICAO24 is already set we do not re-compute it here, see run in Radar - self.ICAO24 = hex(((self._get_msg_byte(1) << 16) - | (self._get_msg_byte(2) << 8) - | self._get_msg_byte(3)))[2:].rstrip('L') + if self['ICAO24'] == "": # if ICAO24 is already set we do not re-compute it here, see run in Radar + self['ICAO24'] = hex(((self._get_msg_byte(1) << 16) + | (self._get_msg_byte(2) << 8) + | self._get_msg_byte(3)))[2:].rstrip('L') self.capability = self._get_msg_byte(0) & 0x07 self.type_code = self._get_msg_byte(4) >> 3 self.emitter_category = self._get_msg_byte(4) & 0x07 self.parity = self.msg & 0xFFFFFF - - if self.downlink_format == self.DF_SHORT_AIR2AIR_SURVEILLANCE_0: + if self['downlink_format'] == self.DF_SHORT_AIR2AIR_SURVEILLANCE_0: self.decode_altitude_msg() - elif self.downlink_format == self.DF_SURVEILLANCE_ALTITUDE_REPLY_4: + elif self['downlink_format'] == self.DF_SURVEILLANCE_ALTITUDE_REPLY_4: self.decode_altitude_msg() self.decode_flight_status_msg() - elif self.downlink_format == self.DF_SURVEILLANCE_IDENTITY_REPLY_5: + elif self['downlink_format'] == self.DF_SURVEILLANCE_IDENTITY_REPLY_5: self.decode_identity_msg() self.decode_flight_status_msg() - elif self.downlink_format == self.DF_LONG_AIR2AIR_SURVEILLANCE_16: + elif self['downlink_format'] == self.DF_ALL_CALL_REPLY_11: + self.decode_all_reply_msg() + elif self['downlink_format'] == self.DF_LONG_AIR2AIR_SURVEILLANCE_16: self.decode_altitude_msg() - elif self.downlink_format == self.DF_ADSB_MSG_17: + elif self['downlink_format'] == self.DF_ADSB_MSG_17: self.decode_ADSB_msg() - elif self.downlink_format == self.DF_EXTENDED_SQUITTER_18: + elif self['downlink_format'] == self.DF_EXTENDED_SQUITTER_18: self.decode_extended_squitter_msg() - elif self.downlink_format == self.DF_COMM_BDS_ALTITUDE_REPLY_20: + elif self['downlink_format'] == self.DF_COMM_BDS_ALTITUDE_REPLY_20: self.decode_comm_bds_reply_msg() self.decode_altitude_msg() self.decode_flight_status_msg() - elif self.downlink_format == self.DF_COMM_BDS_IDENTITY_REPLY_21: + elif self['downlink_format'] == self.DF_COMM_BDS_IDENTITY_REPLY_21: self.decode_comm_bds_reply_msg() self.decode_comm_bds_identity_msg() self.decode_flight_status_msg() + else: + self.logger.info("decode, unknown downlink format: {}".format(self['downlink_format'])) self._update_statistics() diff --git a/tuner.py b/tuner.py index b0ccb32..bfe3d01 100644 --- a/tuner.py +++ b/tuner.py @@ -7,6 +7,8 @@ import rtlsdr import time +__author__ = 'Wolfrax' + """ A Software Defined Radio (SDR) module reading IQ samples from a HW tuner and detects ADS-B messages @@ -37,6 +39,8 @@ def __init__(self, sr=2.0e6, cf=1090e6, gain='max', filename=None): threading.Thread.__init__(self, name="Tuner") basic.ADSB.__init__(self) + self.finished = threading.Event() + self.daemon = True self.logger = logging.getLogger('spots.Tuner') @@ -128,15 +132,16 @@ def _sdr_cb(self, samples, context): samples = self._iq_to_uint(samples) adsb_samples = self._detect_adsb(samples) # This is where we scan for the preamble - basic.statistics.valid_preambles += len(adsb_samples) + basic.statistics['valid_preambles'] += len(adsb_samples) self.data.put(adsb_samples) except Queue.Full: self.logger.error('Queue is full!') - self._die() + self.die() - def _die(self): + def die(self): self.logger.info("Tuner dying...") + self.finished.set() if self._cb_func is not None: self._cb_func(None, stop=True) if self.filename is None: @@ -147,7 +152,7 @@ def _die(self): def read(self, cb_func): self._cb_func = cb_func try: - while True: + while not self.finished.is_set(): try: msgs = self.data.get(timeout=1.0) # Timeout after 1 sec to ensure we are not blocked forever except Queue.Empty: @@ -155,23 +160,10 @@ def read(self, cb_func): self._cb_func(msgs) - # If we are reading from file, sleep for 2 secs to allow for printout, then raise exception and die - if self.cfg_read_from_file and not self.cfg_use_text_display: - time.sleep(2) - raise KeyboardInterrupt + if not self.cfg_run_as_daemon: + # If we are reading from file, sleep for 2 secs to allow for printout, then raise exception and die + if self.cfg_read_from_file and not self.cfg_use_text_display: + time.sleep(2) + self.die() except KeyboardInterrupt: - self._die() - - -def tuner_read(msgs): - print 'found {} signals'.format(len(msgs)) - - -def main(): - tuner1090 = Tuner() - tuner1090.start() - tuner1090.read(tuner_read) - - -if __name__ == '__main__': - main() + self.die()