#!/usr/bin/env python # Copyright 2012 Nick Foster # # This file is part of gr-air-modes # # gr-air-modes is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3, or (at your option) # any later version. # # gr-air-modes is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with gr-air-modes; see the file COPYING. If not, write to # the Free Software Foundation, Inc., 51 Franklin Street, # Boston, MA 02110-1301, USA. # import os, sys, time, threading, datetime, math, csv, tempfile, ConfigParser from optparse import OptionParser from PyQt4 import QtCore,QtGui,QtWebKit from PyQt4.Qwt5 import Qwt from gnuradio import gr, eng_notation from gnuradio.eng_option import eng_option from gnuradio.gr.pubsub import pubsub import air_modes from air_modes.exceptions import * from air_modes.modes_rx_ui import Ui_MainWindow from air_modes.gui_model import * import sqlite3 import zmq class mainwindow(QtGui.QMainWindow): live_data_changed_signal = QtCore.pyqtSignal(QtCore.QString, name='liveDataChanged') def __init__(self): QtGui.QMainWindow.__init__(self) self.ui = Ui_MainWindow() self.ui.setupUi(self) #set defaults #add file, RTL, UHD sources self.ui.combo_source.addItems(["UHD", "Osmocom", "File/UDP"]) self.ui.combo_source.setCurrentIndex(0) #populate antenna, rate combo boxes based on source self.populate_source_options() defaults = self.get_defaults() #should round to actual achieved gain self.ui.line_gain.insert(defaults["gain"]) #default to 5dB self.ui.line_threshold.insert(defaults["threshold"]) if defaults["pmf"] is not None: self.ui.check_pmf.setChecked(bool(defaults["pmf"])) if defaults["dcblock"] is not None: self.ui.check_dcblock.setChecked(bool(defaults["dcblock"])) if defaults["samplerate"] is not None: if defaults["samplerate"] in self.rates: self.ui.combo_rate.setCurrentIndex(self.rates.index(int(defaults["samplerate"]))) self.ui.prog_rssi.setMinimum(-60) self.ui.prog_rssi.setMaximum(0) if defaults["antenna"] is None: self.ui.combo_ant.setCurrentIndex(self.ui.combo_ant.findText("RX2")) else: self.ui.combo_ant.setCurrentIndex(self.ui.combo_ant.findText(defaults["antenna"])) #check KML by default, leave the rest unchecked. self.ui.check_sbs1.setChecked(bool(defaults["sbs1"] == "1")) self.ui.check_raw.setChecked(bool(defaults["raw"] == "1")) self.ui.check_fgfs.setChecked(bool(defaults["fgfs"] == "1")) self.ui.check_kml.setChecked(bool(defaults["kml"] == "1")) self.ui.line_sbs1port.insert(defaults["sbs1port"])#"30003") self.ui.line_rawport.insert(defaults["rawport"])#"9988") self.ui.line_fgfsport.insert(defaults["fgfsport"])#"5500") self.ui.line_kmlfilename.insert(defaults["kmlfile"])#"modes.kml") if defaults["latitude"] is not None: self.ui.line_my_lat.insert(defaults["latitude"]) if defaults["longitude"] is not None: self.ui.line_my_lon.insert(defaults["longitude"]) #disable by default self.ui.check_adsbonly.setCheckState(QtCore.Qt.Unchecked) #set up the radio stuff self.queue = gr.msg_queue(10) self.running = False self.kmlgen = None #necessary bc we stop its thread in shutdown self.dbname = "air_modes.db" self.num_reports = 0 self.last_report = 0 self.context = zmq.Context(1) self.datamodel = dashboard_data_model(None) self.ui.list_aircraft.setModel(self.datamodel) self.ui.list_aircraft.setModelColumn(0) self.az_model = air_modes.az_map_model(None) self.ui.azimuth_map.setModel(self.az_model) #set up dashboard views self.icaodelegate = ICAOViewDelegate() self.ui.list_aircraft.setItemDelegate(self.icaodelegate) self.dashboard_mapper = QtGui.QDataWidgetMapper() self.dashboard_mapper.setModel(self.datamodel) self.dashboard_mapper.addMapping(self.ui.line_icao, 0) #self.dashboard_mapper.addMapping(self.ui.prog_rssi, 2) self.dashboard_mapper.addMapping(self.ui.line_latitude, 3) self.dashboard_mapper.addMapping(self.ui.line_longitude, 4) self.dashboard_mapper.addMapping(self.ui.line_alt, 5) self.dashboard_mapper.addMapping(self.ui.line_speed, 6) #self.dashboard_mapper.addMapping(self.ui.compass_heading, 7) self.dashboard_mapper.addMapping(self.ui.line_climb, 8) self.dashboard_mapper.addMapping(self.ui.line_ident, 9) self.dashboard_mapper.addMapping(self.ui.line_type, 10) self.dashboard_mapper.addMapping(self.ui.line_range, 11) compass_palette = QtGui.QPalette() compass_palette.setColor(QtGui.QPalette.Foreground, QtCore.Qt.white) self.ui.compass_heading.setPalette(compass_palette) self.ui.compass_bearing.setPalette(compass_palette) #TODO: change the needle to an aircraft silhouette self.ui.compass_heading.setNeedle(Qwt.QwtDialSimpleNeedle(Qwt.QwtDialSimpleNeedle.Ray, False, QtCore.Qt.black)) self.ui.compass_bearing.setNeedle(Qwt.QwtDialSimpleNeedle(Qwt.QwtDialSimpleNeedle.Ray, False, QtCore.Qt.black)) #hook up the update signal self.ui.list_aircraft.selectionModel().currentRowChanged.connect(self.dashboard_mapper.setCurrentModelIndex) self.ui.list_aircraft.selectionModel().currentRowChanged.connect(self.update_heading_widget) self.ui.list_aircraft.selectionModel().currentRowChanged.connect(self.update_bearing_widget) self.ui.list_aircraft.selectionModel().currentRowChanged.connect(self.update_rssi_widget) self.ui.list_aircraft.selectionModel().currentRowChanged.connect(self.update_map_highlight) self.datamodel.dataChanged.connect(self.unmapped_widgets_dataChanged) #hook up parameter-changed signals so we can change gain, rate, etc. while running self.ui.combo_rate.currentIndexChanged['QString'].connect(self.update_sample_rate) self.ui.line_gain.editingFinished.connect(self.update_gain) self.ui.combo_source.currentIndexChanged['QString'].connect(self.populate_source_options) #hook up live data text box update signal self.live_data_changed_signal.connect(self.on_append_live_data) self._last_live_data_update = time.time() self._pending_msgstr = "" self.prefs = None def update_sample_rate(self, rate): if self.running: self._radio.set_rate(int(float(rate)*1e6)) def update_gain(self): if self.running: self._radio.set_gain(float(self.ui.line_gain.text())) ############ widget update functions for non-mapped widgets ############ def update_heading_widget(self, index): if index.model() is not None: heading = index.model().data(index.model().index(index.row(), self.datamodel._colnames.index("heading"))).toDouble()[0] self.ui.compass_heading.setValue(heading) def update_bearing_widget(self, index): if index.model() is not None: bearing = index.model().data(index.model().index(index.row(), self.datamodel._colnames.index("bearing"))).toDouble()[0] self.ui.compass_bearing.setValue(bearing) def unmapped_widgets_dataChanged(self, startIndex, endIndex): index = self.ui.list_aircraft.selectionModel().currentIndex() if index.row() in range(startIndex.row(), endIndex.row()+1): #the current aircraft was affected if self.datamodel._colnames.index("heading") in range(startIndex.column(), endIndex.column()+1): self.update_heading_widget(index) if self.datamodel._colnames.index("bearing") in range(startIndex.column(), endIndex.column()+1): self.update_bearing_widget(index) if self.datamodel._colnames.index("rssi") in range(startIndex.column(), endIndex.column()+1): self.update_rssi_widget(index) def update_rssi_widget(self, index): if index.model() is not None: rssi = index.model().data(index.model().index(index.row(), 2)).toDouble()[0] self.ui.prog_rssi.setValue(rssi) def increment_reportspersec(self, msg): self.num_reports += 1 def update_reportspersec(self): dt = time.time() - self.last_report if dt >= 1.0: self.last_report = time.time() self.ui.line_reports.setText("%i" % self.num_reports) self.num_reports = 0 def update_map_highlight(self, index): if index.model() is not None: icaostr = index.model().data(index.model().index(index.row(), self.datamodel._colnames.index("icao"))).toString() icao = int(str(icaostr), 16) self.jsonpgen.set_highlight(icao) ##################### dynamic option population ######################## #goes and gets valid antenna, sample rate options from the device and grays out appropriate things def populate_source_options(self): sourceid = self.ui.combo_source.currentText() self.rates = [] self.ratetext = [] self.antennas = [] if sourceid == "UHD": try: from gnuradio import uhd self.src = uhd.single_usrp_source("", uhd.io_type_t.COMPLEX_FLOAT32, 1) self.rates = [rate.start() for rate in self.src.get_samp_rates() if (rate.start() % 2.e6) == 0 and rate >= 4e6] self.antennas = self.src.get_antennas() self.src = None #deconstruct UHD source for now self.ui.combo_ant.setEnabled(True) self.ui.combo_rate.setEnabled(True) self.ui.stack_source.setCurrentIndex(0) except: self.rates = [] self.antennas = [] self.ui.combo_ant.setEnabled(False) self.ui.combo_rate.setEnabled(False) self.ui.stack_source.setCurrentIndex(0) elif sourceid == "Osmocom": try: import osmosdr self.src = osmosdr.source("") self.rates = [rate.start() for rate in self.src.get_sample_rates() if ((rate.start() % 2.e6) == 0) or (rate.start() < 4.e6 and ((rate.start()%0.2e6) == 0))] self.antennas = ["RX"] self.src = None self.ui.combo_ant.setEnabled(False) self.ui.combo_rate.setEnabled(True) self.ui.stack_source.setCurrentIndex(0) except: self.rates = [] self.antennas = [] self.ui.combo_ant.setEnabled(False) self.ui.combo_rate.setEnabled(False) self.ui.stack_source.setCurrentIndex(0) elif sourceid == "File/UDP": self.rates = [2e6*i for i in range(2,13)] self.antennas = ["None"] self.ui.combo_ant.setEnabled(False) self.ui.combo_rate.setEnabled(True) self.ui.stack_source.setCurrentIndex(1) self.ui.combo_rate.clear() self.ratetext = ["%.3f" % (rate / 1.e6) for rate in self.rates] for rate, text in zip(self.rates, self.ratetext): self.ui.combo_rate.addItem(text, rate) self.ui.combo_ant.clear() self.ui.combo_ant.addItems(self.antennas) #set up recommended sample rate if len(self.rates) > 1: if max(self.rates) > 4.e6: recommended_rate = min(x for x in self.rates if x >= 4e6 and max(self.rates) % x == 0) else: recommended_rate = max(self.rates) if recommended_rate >= 8.e6: self.ui.check_pmf.setChecked(True) else: self.ui.check_pmf.setChecked(False) self.ui.combo_rate.setCurrentIndex(self.rates.index(recommended_rate)) ################ action handlers #################### def on_combo_source_currentIndexChanged(self, index): self.populate_source_options() def on_button_start_released(self): #if we're already running, kill it! if self.running is True: self.on_quit() self.num_reports = 0 self.ui.line_reports.setText("0") self.ui.button_start.setText("Start") self.running = False else: #we aren't already running, let's get this party started parser = OptionParser(option_class=eng_option) air_modes.modes_radio.add_radio_options(parser) (options, args) = parser.parse_args() #sets defaults nicely if str(self.ui.combo_source.currentText()) != "File/UDP": options.source = str(self.ui.combo_source.currentText()).lower() else: options.source = str(self.ui.line_inputfile.text()) options.rate = float(self.ui.combo_rate.currentText()) * 1e6 options.antenna = str(self.ui.combo_ant.currentText()) options.gain = float(self.ui.line_gain.text()) options.threshold = float(self.ui.line_threshold.text()) options.pmf = self.ui.check_pmf.isChecked() options.dcblock = self.ui.check_dcblock.isChecked() self._servers = ["inproc://modes-radio-pub"] #TODO ADD REMOTES self._relay = air_modes.zmq_pubsub_iface(self.context, subaddr=self._servers, pubaddr=None) if self.ui.check_raw.checkState(): options.tcp = int(self.ui.line_rawport.text()) self._radio = air_modes.modes_radio(options, self.context) self._publisher = pubsub() self._relay.subscribe("dl_data", air_modes.make_parser(self._publisher)) try: my_position = [float(self.ui.line_my_lat.text()), float(self.ui.line_my_lon.text())] except: my_position = None self._cpr_dec = air_modes.cpr_decoder(my_position) self.datamodelout = dashboard_output(self._cpr_dec, self.datamodel, self._publisher) self.lock = threading.Lock() #grab a lock to ensure sql and kml don't step on each other #output options to populate outputs, updates if self.ui.check_kml.checkState(): #we spawn a thread to run every 30 seconds (or whatever) to generate KML self.kmlgen = air_modes.output_kml(self.ui.line_kmlfilename.text(), self.dbname, my_position, self.lock) #create a KML generating thread if self.ui.check_sbs1.checkState(): sbs1port = int(self.ui.line_sbs1port.text()) sbs1out = air_modes.output_sbs1(self._cpr_dec, sbs1port, self._publisher) if self.ui.check_fgfs.checkState(): fghost = "127.0.0.1" #TODO FIXME fgport = self.ui.line_fgfsport.text() fgout = air_modes.output_flightgear(self._cpr_dec, fghost, int(fgport), self._publisher) #add azimuth map output and hook it up if my_position is not None: self.az_map_output = air_modes.az_map_output(self._cpr_dec, self.az_model, self._publisher) #self._relay.subscribe("dl_data", self.az_map_output.output) #set up map #NOTE this is busted on windows. WebKit requires .htm[l] extensions to render, #so using a temp file doesn't work. self._htmlfile = open("/tmp/mode_s.html", 'wb+')#tempfile.NamedTemporaryFile() self._jsonfile = tempfile.NamedTemporaryFile() self.livedata = air_modes.output_print(self._cpr_dec, self._publisher, self.live_data_changed_signal.emit) #create SQL database for KML and dashboard displays self.dbwriter = air_modes.output_sql(self._cpr_dec, self.dbname, self.lock, self._publisher) self.jsonpgen = air_modes.output_jsonp(self._jsonfile.name, self.dbname, my_position, self.lock, timeout=1) htmlstring = air_modes.html_template(my_position, self._jsonfile.name) self._htmlfile.write(htmlstring) self._htmlfile.flush() class WebPage(QtWebKit.QWebPage): def javaScriptConsoleMessage(self, msg, line, source): print '%s line %d: %s' % (source, line, msg) page = WebPage() self.ui.mapView.setPage(page) self.ui.mapView.load( QtCore.QUrl( QtCore.QUrl.fromLocalFile("/tmp/mode_s.html") ) ) self.ui.mapView.show() #output to update reports/sec widget self._relay.subscribe("dl_data", self.increment_reportspersec) self._rps_timer = QtCore.QTimer() self._rps_timer.timeout.connect(self.update_reportspersec) self._rps_timer.start(1000) #start the flowgraph self._radio.start() self.ui.button_start.setText("Stop") self.running = True #grab prefs and save them self.prefs = {} self.prefs["samplerate"] = options.rate self.prefs["antenna"] = options.antenna self.prefs["gain"] = options.gain self.prefs["pmf"] = "1" if options.pmf else "0" self.prefs["dcblock"] = "1" if options.dcblock else "0" self.prefs["source"] = self.ui.combo_source.currentText() self.prefs["threshold"] = options.threshold self.prefs["sbs1"] = "1" if self.ui.check_sbs1.isChecked() else "0" self.prefs["sbs1port"] = int(self.ui.line_sbs1port.text()) self.prefs["fgfs"] = "1" if self.ui.check_fgfs.isChecked() else "0" self.prefs["fgfsport"] = int(self.ui.line_fgfsport.text()) self.prefs["raw"] = "1" if self.ui.check_raw.isChecked() else "0" self.prefs["rawport"] = int(self.ui.line_rawport.text()) self.prefs["kml"] = "1" if self.ui.check_kml.isChecked() else "0" self.prefs["kmlfile"] = self.ui.line_kmlfilename.text() try: self.prefs["latitude"] = float(self.ui.line_my_lat.text()) self.prefs["longitude"] = float(self.ui.line_my_lon.text()) except: pass def on_quit(self): if self.running is True: self._radio.close() self._radio = None self._relay.close() self._relay = None self._rps_timer = None try: self.kmlgen.done = True #TODO FIXME need a way to kill kmlgen safely without delay #self.kmlgen.join() #self.kmlgen = None except: pass if self.prefs is not None: self.write_defaults(self.prefs) #slot to catch signal emitted by output_live_data (necessary for #thread safety since output_live_data is called by another thread) def on_append_live_data(self, msgstr): self._pending_msgstr += msgstr + "\n" if time.time() - self._last_live_data_update >= 0.1: self._last_live_data_update = time.time() self.update_live_data(self._pending_msgstr) self._pending_msgstr = "" def update_live_data(self, msgstr): #limit scrollback buffer size -- is there a faster way? if(self.ui.text_livedata.document().lineCount() > 500): cursor = self.ui.text_livedata.textCursor() cursor.movePosition(QtGui.QTextCursor.Start) cursor.select(QtGui.QTextCursor.LineUnderCursor) cursor.removeSelectedText() self.ui.text_livedata.append(msgstr) self.ui.text_livedata.verticalScrollBar().setSliderPosition(self.ui.text_livedata.verticalScrollBar().maximum()) opt_file = "~/.gr-air-modes/prefs" def get_defaults(self): defaults = {} defaults["samplerate"] = None #let app pick it defaults["pmf"] = None defaults["dcblock"] = None defaults["antenna"] = None defaults["gain"] = "25" defaults["kml"] = "1" defaults["kmlfile"] = "modes.kml" defaults["sbs1"] = "0" defaults["sbs1port"] = "30003" defaults["raw"] = "0" defaults["rawport"] = "9988" defaults["fgfs"] = "0" defaults["fgfsport"] = "5500" defaults["source"] = "UHD" defaults["threshold"] = "5" defaults["latitude"] = None defaults["longitude"] = None prefs = ConfigParser.ConfigParser(defaults) prefs.optionxform = str try: prefs.read(os.path.expanduser(self.opt_file)) for item in prefs.items("GUI"): defaults[item[0]] = item[1] except (IOError, ConfigParser.NoSectionError): print "No preferences file %s found, creating..." % os.path.expanduser(self.opt_file) self.write_defaults(defaults) return defaults def write_defaults(self, defaults): config = ConfigParser.RawConfigParser() config.add_section('GUI') for item in defaults: config.set('GUI', item, str(defaults[item])) dirname = os.path.dirname(os.path.expanduser(self.opt_file)) if not os.path.exists(dirname): os.makedirs(dirname) with open(os.path.expanduser(self.opt_file), 'wb') as prefsfile: config.write(prefsfile) if __name__ == '__main__': app = QtGui.QApplication(sys.argv) window = mainwindow() app.lastWindowClosed.connect(window.on_quit) window.setWindowTitle("Mode S/ADS-B receiver") window.show() sys.exit(app.exec_())