diff --git a/README.md b/README.md index 7480bcd..c47b54e 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,25 @@ This is a GNU Radio (GR) based SDR scanner with a Curses interface, primarily me http://youtu.be/BXptQFSV8E4 -![GUI screenshot](https://github.com/madengr/ham2mon/blob/master/ham2mon.png) +Original screenshot +![GUI screenshot](https://github.com/lordmorgul/ham2mon/blob/master/ham2mon.png) + +Additional screenshots show updated screen color and channel highlighting with +![GUI screenshot](https://github.com/lordmorgul/ham2mon/blob/master/ham2mon_priority_channels_inactive_noise.png) +![GUI screenshot](https://github.com/lordmorgul/ham2mon/blob/master/ham2mon_priority_channels_active.png) +![GUI screenshot](https://github.com/lordmorgul/ham2mon/blob/master/ham2mon_priority_channels_overmax.png) + ## Tested with: +Recent development and tests on Python3: +- RTL-SDR v3 RTL2832 + R820T at 2 Msps (http://rtl-sdr.com) +- NooElec RTL2832 + R820T at 2 Msps (http://www.nooelec.com) +- GNU Radio 3.8.2.0 (https://github.com/gnuradio/gnuradio) +- GrOsmoSDR 0.1.4-29 (http://sdr.osmocom.org/trac/wiki/GrOsmoSDR) +- Airspy Mini (https://airspy.com/airspy-mini/) +- Python 3.8.6 + +Previous version tests: - Ettus B200 at 16 Msps (http://www.ettus.com) - NooElec RTL2832 + R820T at 2 Msps (http://www.nooelec.com) - GNU Radio 3.7.10 (https://github.com/gnuradio/gnuradio) @@ -13,6 +29,35 @@ http://youtu.be/BXptQFSV8E4 - Ettus UHD 3.10.0 (https://github.com/EttusResearch/uhd) ## Contributors: +lordmorgul: +- Min and max spectrum switches +- Python3 builtin functions correction for priority and lockout parsing +- Example priority and lockout files +- Spectrum bar coloration (min/threshold/max) +- Active channel tracking and coloration +- GUI adjustments in channel and receiver windows, borders and labels +- priority, lockout, and text log file name displays +- pulled logger framework from kibihrchak and revised to python3 +- log file framework with enable flags (to prepare for multiple loggers implemented, text and database) +- log file timeout so active channels are indicated only every TIMEOUT seconds +- pulled long run demodulator fix to python3 version from john +- pulled gain corrections to python3 version from john +- defined max file size to save from command line option +- channel width configurable from command line option +- incorporate miweber67 freq range limits + +miweber67 +- frequency range to limit selected channels to within specific limit + +john-: +- long running file end (demodulator run time limit) +- gain config corrections, available gains detected from gnuradio and other inputs ignored / not shown + +kibihrchak: +- Logger branch text file log entries + +ta6o: +- Initial python3 fixes for syntax m0mik: - Added HackRF IF/BB gain parameters @@ -36,8 +81,11 @@ madengr: - AM demodulation - Priority channels + ## Console Operation: +Options are displayed using ./parser.py -h + The following is an example of the option switches for UHD with NBFM demodulation, although omission of any will use default values (shown below) that are optimal for the B200: ./ham2mon.py -a "uhd" -n 8 -d 0 -f 146E6 -r 4E6 -g 30 -s -60 -v 0 -t 10 -w @@ -58,13 +106,21 @@ Example of reading from an IQ file: ./ham2mon.py -a "file=gqrx.raw,rate=8E6,repeat=false,throttle=true,freq=466E6" -r 8E6 -w +##Channel Detection Log File + +For console operation, it is possible to specify the log file name, in which channel detection, and removal will be logged. The option is `--log_file=`. + +Whenever a channel appears/dissapears, new line will be written in the log file. For the line format, check `__print_channel_log__()` in `scanner.Scanner`. + +Active channels are flagged as active periodically based on the active channel logging timeout. + ## GUI Controls: `t/r = Detection threshold +/- 5 dB. (T/R for +/- 1dB)` -`p/o = Spectrum upper scale +/- 10 dB` +`p/o = Spectrum upper scale +/- 5 dB` -`w/q = Spectrum lower scale +/- 10 dB` +`w/q = Spectrum lower scale +/- 5 dB` `g/f = RF gain +/- 10 dB (G/F for +/- 1dB)` @@ -92,6 +148,8 @@ Example of reading from an IQ file: `CTRL-C = quit` +`SHIFT-R = quit` + ## Help Menu `Usage: ham2mon.py [options]` @@ -112,6 +170,9 @@ Example of reading from an IQ file: ` -f CENTER_FREQ, --freq=CENTER_FREQ` ` Hardware RF center frequency in Hz` +` -e RANGE, --range=RANGE` +` Limit reception to specified range, xx-yy in Hz` + ` -r ASK_SAMP_RATE, --rate=ASK_SAMP_RATE` ` Hardware ask sample rate in sps (1E6 minimum)` @@ -121,9 +182,15 @@ Example of reading from an IQ file: ` -i IF_GAIN_DB, --if_gain=IF_GAIN_DB` ` Hardware IF gain in dB` +` -j IF_GAIN_DB, --lna_gain=LNA_GAIN_DB` +` Hardware LNA gain in dB` + ` -o BB_GAIN_DB, --bb_gain=BB_GAIN_DB` ` Hardware BB gain in dB` +` -x MIX_GAIN_DB, --mix_gain=MIX_GAIN_DB` +` Hardware MIX gain in dB` + ` -s SQUELCH_DB, --squelch=SQUELCH_DB` ` Squelch in dB` @@ -141,6 +208,12 @@ Example of reading from an IQ file: ` -p PRIORITY_FILE_NAME, --priority=PRIORITY_FILE_NAME` ` File of EOL delimited priority channels in Hz` +` -L CHANNEL_LOG_FILE_NAME, --log-file=CHANNEL_LOG_FILE_NAME` +` File for output of channel activity (demod lock/unlock) and active channels detection` + +` -A LOG_ACTIVE_TIMEOUT, --log_active_timeout=LOG_ACTIVE_TIMEOUT` +` Timeout delay between marking a channel active in the log file in seconds` + ` -c FREQ_CORRECTION, --correction=FREQ_CORRECTION` ` Frequency correction in ppm` @@ -149,6 +222,19 @@ Example of reading from an IQ file: ` -b AUDIO_BPS, --bps=AUDIO_BPS` ` Audio bit depth (bps)` +` -M MAX_DB, --max_db=MAX_DB` +` Spectrum window maximum in dB` + +` -N MIN_DB, --min_db=MIN_DB` +` Spectrum window minimum in dB` +` -k MAX_DEMOD_LENGTH, --max-demod-length=MAX_DEMOD_LENGTH` +` Maxumum length for a demodulation (sec)` +` -B CHANNEL_SPACING, --channel-spacing=CHANNEL_SPACING` +` Channel spacing (spectrum bin size)` +` -F MIN_FILE_SIZE, --min-file-size=MIN_FILE_SIZE` +` Minimum size file to save in bytes, default 0 (save all)` + + ## Description: The high speed signal processing is done in GR and the logic & control in Python. There are no custom GR blocks. The GUI is written in Curses and is meant to be lightweight. See the video for a basic overview. I attempted to make the program very object oriented and “Pythonic”. Each module runs on it's own for testing purposes. diff --git a/apps/am_flow_example.py b/apps/am_flow_example.py index 0517730..d49876e 100755 --- a/apps/am_flow_example.py +++ b/apps/am_flow_example.py @@ -14,7 +14,7 @@ x11 = ctypes.cdll.LoadLibrary('libX11.so') x11.XInitThreads() except: - print "Warning: failed to XInitThreads()" + print("Warning: failed to XInitThreads()") from PyQt4 import Qt from gnuradio import analog diff --git a/apps/cursesgui.py b/apps/cursesgui.py index 2896f70..d1b40cf 100755 --- a/apps/cursesgui.py +++ b/apps/cursesgui.py @@ -54,6 +54,7 @@ def draw_spectrum(self, data): 1.0E+00 draws 10 rows 1.0E+01 draws 10 rows """ + # Keep min_db to 10 dB below max_db if self.min_db > (self.max_db - 10): self.min_db = self.max_db - 10 @@ -87,16 +88,33 @@ def draw_spectrum(self, data): pos_y = pos_y.astype(int) # Clear previous contents, draw border, and title - self.win.clear() +# self.win.clear() + # using erase prevents jitter in some terminals compared to clear() directly + self.win.erase() self.win.border(0) - self.win.addnstr(0, self.dims[1]/2-4, "SPECTRUM", 8, - curses.color_pair(4)) + self.win.attron(curses.color_pair(6)) + self.win.addnstr(0, int(self.dims[1]/2-6), "SPECTRUM", 8, + curses.color_pair(6) | curses.A_DIM | curses.A_BOLD) + + # Generate threshold line, clip to window, and convert to int + pos_yt = (self.threshold_db - self.max_db) * scale + pos_yt = np.clip(pos_yt, min_y, max_y-1) + pos_yt = pos_yt.astype(int) # Draw the bars for pos_x in range(len(pos_y)): # Invert the y fill since we want bars # Offset x (column) by 1 so it does not start on the border - self.win.vline(pos_y[pos_x], pos_x+1, "*", max_y-pos_y[pos_x]) + if pos_y[pos_x] > pos_yt: + # bar is below threshold, use low value color + self.win.vline(pos_y[pos_x], pos_x+1, "-", max_y-pos_y[pos_x],curses.color_pair(3) | curses.A_BOLD) + elif pos_y[pos_x] <= min_y: + # bar is above max (clipped to min y), use max value color + self.win.vline(pos_y[pos_x], pos_x+1, "+", max_y-pos_y[pos_x],curses.color_pair(1) | curses.A_BOLD) + else: + # bar is between max value and threshold, use threshold color + self.win.vline(pos_y[pos_x], pos_x+1, "*", max_y-pos_y[pos_x],curses.color_pair(2) | curses.A_BOLD) + # Draw the max_db and min_db strings string = ">" + "%+03d" % self.max_db @@ -106,26 +124,22 @@ def draw_spectrum(self, data): self.win.addnstr(max_y, 1 + self.dims[1] - self.chars, string, self.chars, curses.color_pair(3)) - # Generate threshold line, clip to window, and convert to int - pos_yt = (self.threshold_db - self.max_db) * scale - pos_yt = np.clip(pos_yt, min_y, max_y-1) - pos_yt = pos_yt.astype(int) - # Draw the theshold line # x=1 start to account for left border - self.win.hline(pos_yt, 1, "-", len(pos_y)) + self.win.hline(pos_yt, 1, "-", len(pos_y), curses.color_pair(2)) # Draw the theshold string string = ">" + "%+03d" % self.threshold_db self.win.addnstr(pos_yt, (1 + self.dims[1] - self.chars), string, self.chars, curses.color_pair(2)) - # Hide cursor + # Hide cursor self.win.leaveok(1) # Update virtual window self.win.noutrefresh() + def proc_keyb(self, keyb): """Process keystrokes @@ -149,13 +163,13 @@ def proc_keyb(self, keyb): self.threshold_db -= 1 return True elif keyb == ord('p'): - self.max_db += 10 + self.max_db += 5 elif keyb == ord('o'): - self.max_db -= 10 + self.max_db -= 5 elif keyb == ord('w'): - self.min_db += 10 + self.min_db += 5 elif keyb == ord('q'): - self.min_db -= 10 + self.min_db -= 5 else: pass return False @@ -181,7 +195,7 @@ def __init__(self, screen): self.win = curses.newwin(height, width, height + 3, 1) self.dims = self.win.getmaxyx() - def draw_channels(self, gui_tuned_channels): + def draw_channels(self, gui_tuned_channels, gui_active_channels): """Draws tuned channels list Args: @@ -189,10 +203,13 @@ def draw_channels(self, gui_tuned_channels): """ # Clear previous contents, draw border, and title - self.win.clear() +# self.win.clear() + # using erase prevents jitter in some terminals compared to clear() directly + self.win.erase() self.win.border(0) - self.win.addnstr(0, self.dims[1]/2-4, "CHANNELS", 8, - curses.color_pair(4)) + self.win.attron(curses.color_pair(6)) + self.win.addnstr(0, int(self.dims[1]/4), "CHANNELS", 8, + curses.color_pair(6) | curses.A_DIM | curses.A_BOLD) # Limit the displayed channels to no more than two rows max_length = 2*(self.dims[0]-2) @@ -201,15 +218,28 @@ def draw_channels(self, gui_tuned_channels): else: pass + active_channels = set(gui_active_channels) + # Draw the tuned channels prefixed by index in list (demodulator index) + # Use color if tuned channel is in active channel list during this scan_cycle for idx, gui_tuned_channel in enumerate(gui_tuned_channels): - text = str(idx) + ": " + gui_tuned_channel + text = str(idx) + text = text.zfill(2) + ": " + gui_tuned_channel if idx < self.dims[0]-2: # Display in first column - self.win.addnstr(idx+1, 1, text, 11) + # text color based on activity + # curses.color_pair(5) + if gui_tuned_channel in active_channels: + self.win.addnstr(idx+1, 1, text, 11, curses.color_pair(2) | curses.A_BOLD) + else: + self.win.addnstr(idx+1, 1, text, 11, curses.color_pair(6)) else: # Display in second column self.win.addnstr(idx-self.dims[0]+3, 13, text, 11) + if gui_tuned_channel in active_channels: + self.win.addnstr(idx-self.dims[0]+3, 13, text, 11, curses.color_pair(2)) + else: + self.win.addnstr(idx-self.dims[0]+3, 13, text, 11) # Hide cursor self.win.leaveok(1) @@ -238,24 +268,33 @@ def __init__(self, screen): self.win = curses.newwin(height, width, height + 3, 26) self.dims = self.win.getmaxyx() - def draw_channels(self, gui_lockout_channels): - """Draws tuned channels list + def draw_channels(self, gui_lockout_channels, gui_active_channels): + """Draws lockout channels list Args: rf_channels [string]: List of strings in MHz """ # Clear previous contents, draw border, and title - self.win.clear() +# self.win.clear() + # using erase prevents jitter in some terminals compared to clear() directly + self.win.erase() self.win.border(0) - self.win.addnstr(0, self.dims[1]/2-3, "LOCKOUT", 7, - curses.color_pair(4)) + self.win.attron(curses.color_pair(6)) + self.win.addnstr(0, int(self.dims[1]/2-3), "LOCKOUT", 7, + curses.color_pair(6) | curses.A_DIM | curses.A_BOLD) + + active_channels = set(gui_active_channels) # Draw the lockout channels + # Use color if lockout channel is in active channel list during this scan_cycle for idx, gui_lockout_channel in enumerate(gui_lockout_channels): # Don't draw past height of window if idx <= self.dims[0]-3: text = " " + gui_lockout_channel - self.win.addnstr(idx+1, 1, text, 11) + if gui_lockout_channel in active_channels: + self.win.addnstr(idx+1, 1, text, 11, curses.color_pair(5) | curses.A_BOLD) + else: + self.win.addnstr(idx+1, 1, text, 11, curses.color_pair(6)) else: pass @@ -309,14 +348,16 @@ class RxWindow(object): Attributes: center_freq (float): Hardware RF center frequency in Hz samp_rate (float): Hardware sample rate in sps (1E6 min) - gain_db (int): Hardware RF gain in dB - if_gain_db (int): Hardware IF gain in dB - bb_gain_db (int): Hardware BB gain in dB + gains (list): Hardware gains in dB squelch_db (int): Squelch in dB volume_dB (int): Volume in dB + type_demod (int): Type of demodulation (0 = FM, 1 = AM) record (bool): Record audio to file if True lockout_file_name (string): Name of file with channels to lockout priority_file_name (string): Name of file with channels for priority + channel_log_file_name (string): Name of file for channel activity logging + channel_log_timeout (int): Timeout delay between logging active state of channel in seconds + log_mode (string): Log system mode (file, database type) """ # pylint: disable=too-many-instance-attributes @@ -325,17 +366,25 @@ def __init__(self, screen): # Set default values self.center_freq = 146E6 + self.min_freq = 144E6 + self.max_freq = 148E6 + self.freq_low = 144E6 + self.freq_max = 148E6 self.samp_rate = 2E6 self.freq_entry = 'None' - self.gain_db = 0 - self.if_gain_db = 16 - self.bb_gain_db = 16 self.squelch_db = -60 self.volume_db = 0 self.type_demod = 0 self.record = True self.lockout_file_name = "" self.priority_file_name = "" + self.channel_log_file_name = "" + self.channel_log_timeout = 15 + # nothing other than file logging defined + if (self.channel_log_file_name != ""): + self.log_mode = "file" + else: + self.log_mode = "none" # Create a window object in the bottom half of the screen # Make it about 1/3 the screen width @@ -351,58 +400,148 @@ def draw_rx(self): """Draws receiver paramaters """ - # Clear previous contents, draw border, and title - self.win.clear() + # Erase previous contents, draw border, and title + # using erase prevents jitter in some terminals compared to clear() directly + self.win.erase() self.win.border(0) - self.win.addnstr(0, self.dims[1]/2-4, "RECEIVER", 8, - curses.color_pair(4)) + self.win.attron(curses.color_pair(6)) + self.win.addnstr(0, int(self.dims[1]/2-4), "RECEIVER", 8, + curses.color_pair(6) | curses.A_DIM | curses.A_BOLD) # Draw the receiver info prefix fields - text = "RF Freq (MHz) : " - self.win.addnstr(1, 1, text, 15) - text = "RF Gain (dB) : " - self.win.addnstr(2, 1, text, 15) - text = "IF Gain (dB) : " - self.win.addnstr(3, 1, text, 15) - text = "BB Gain (dB) : " - self.win.addnstr(4, 1, text, 15) - text = "BB Rate (Msps): " - self.win.addnstr(5, 1, text, 15) - text = "BB Sql (dB) : " - self.win.addnstr(6, 1, text, 15) - text = "AF Vol (dB) : " - self.win.addnstr(7, 1, text, 15) - text = "Record : " - self.win.addnstr(8, 1, text, 15) - text = "Demod Type : " - self.win.addnstr(9, 1, text, 15) - text = "Files : " - self.win.addnstr(10, 1, text, 15) + index = 1 + text = "RF Freq (MHz) : " + self.win.addnstr(1, 1, text, 18, curses.color_pair(6)) + + index = index+1 + text = "Min Freq (MHz) : " + self.win.addnstr(2, 1, text, 18, curses.color_pair(6)) + + index = index+1 + text = "Max Freq (MHz) : " + self.win.addnstr(3, 1, text, 18, curses.color_pair(6)) + + index = index+1 + text = "Low Tune (MHz) : " + self.win.addnstr(4, 1, text, 18, curses.color_pair(6)) + + index = index+1 + text = "High Tune (MHz): " + self.win.addnstr(index, 1, text, 18, curses.color_pair(6)) + + for index2, gain in enumerate(self.gains, 2): + text = "{} Gain (dB){} : ".format(gain["name"], (4-len(gain["name"]))*' ') + self.win.addnstr(index+index2-1, 1, text, 18) + index3 = index2 + + index = index+index3 + text = "BB Rate (Msps) : " + self.win.addnstr(index, 1, text, 18, curses.color_pair(6)) + + index = index+1 + text = "BB Sql (dB) : " + self.win.addnstr(index, 1, text, 18, curses.color_pair(6)) + + index = index+1 + text = "AF Vol (dB) : " + self.win.addnstr(index, 1, text, 18, curses.color_pair(6)) + + index = index+1 + text = "Record : " + self.win.addnstr(index, 1, text, 18, curses.color_pair(6)) + + index = index+1 + text = "Demod Type : " + self.win.addnstr(index, 1, text, 18, curses.color_pair(6)) + + index = index+1 + text = "Lockout File : " + self.win.addnstr(index, 1, text, 18, curses.color_pair(6)) + + index = index+1 + text = "Priority File : " + self.win.addnstr(index, 1, text, 18, curses.color_pair(6)) + + index = index+1 + text = "Log File : " + self.win.addnstr(index, 1, text, 18, curses.color_pair(6)) + + index = index+1 + text = "Log Timeout (s): " + self.win.addnstr(index, 1, text, 18, curses.color_pair(6)) + + index = index+1 + text = "Log Mode : " + self.win.addnstr(index, 1, text, 18, curses.color_pair(6)) # Draw the receiver info suffix fields - if self.freq_entry <> 'None': + index = 1 + if self.freq_entry != 'None': text = self.freq_entry else: text = '{:.3f}'.format((self.center_freq)/1E6) - self.win.addnstr(1, 17, text, 8, curses.color_pair(5)) - text = str(self.gain_db) - self.win.addnstr(2, 17, text, 8, curses.color_pair(5)) - text = str(self.if_gain_db) - self.win.addnstr(3, 17, text, 8, curses.color_pair(5)) - text = str(self.bb_gain_db) - self.win.addnstr(4, 17, text, 8, curses.color_pair(5)) + self.win.addnstr(index, 20, text, 8, curses.color_pair(5)) + + index = index+1 + text = '{:.3f}'.format((self.min_freq)/1E6) + self.win.addnstr(index, 20, text, 8, curses.color_pair(5)) + + index = index+1 + text = '{:.3f}'.format((self.max_freq)/1E6) + self.win.addnstr(index, 20, text, 8, curses.color_pair(5)) + + index = index+1 + text = '{:.3f}'.format((self.freq_low)/1E6) + self.win.addnstr(index, 20, text, 8, curses.color_pair(5)) + + index = index+1 + text = '{:.3f}'.format((self.freq_high)/1E6) + self.win.addnstr(index, 20, text, 8, curses.color_pair(5)) + + for index2, gain in enumerate(self.gains, 2): + text = str(gain["value"]) + self.win.addnstr(index+index2-1, 20, text, 8, curses.color_pair(5)) + index3 = index2 + + index = index+index3 text = str(self.samp_rate/1E6) - self.win.addnstr(5, 17, text, 8) + self.win.addnstr(index, 20, text, 8, curses.color_pair(5)) + + index = index+1 text = str(self.squelch_db) - self.win.addnstr(6, 17, text, 8, curses.color_pair(5)) + self.win.addnstr(index, 20, text, 8, curses.color_pair(5)) + + index = index+1 text = str(self.volume_db) - self.win.addnstr(7, 17, text, 8, curses.color_pair(5)) + self.win.addnstr(index, 20, text, 8, curses.color_pair(5)) + + index = index+1 text = str(self.record) - self.win.addnstr(8, 17, text, 8) + self.win.addnstr(index, 20, text, 8, curses.color_pair(6)) + + index = index+1 text = str(self.type_demod) - self.win.addnstr(9, 17, text, 8) - text = str(self.lockout_file_name) + " " + str(self.priority_file_name) - self.win.addnstr(10, 17, text, 20) + self.win.addnstr(index, 20, text, 8, curses.color_pair(6)) + + index = index+1 + text = str(self.lockout_file_name) + self.win.addnstr(index, 20, text, 20, curses.color_pair(6)) + + index = index+1 + text = str(self.priority_file_name) + self.win.addnstr(index, 20, text, 20, curses.color_pair(6)) + + index = index+1 + text = str(self.channel_log_file_name) + self.win.addnstr(index, 20, text, 20, curses.color_pair(6)) + + index = index+1 + text = str(self.channel_log_timeout) + self.win.addnstr(index, 20, text, 20, curses.color_pair(5)) + + index = index+1 + text = str(self.log_mode) + self.win.addnstr(index, 20, text, 20, curses.color_pair(6)) # Hide cursor self.win.leaveok(1) @@ -471,7 +610,7 @@ def proc_keyb_hard(self, keyb): pass self.freq_entry = 'None' return True - elif self.freq_entry <> 'None' and (keyb - 48 in range (10) or keyb == ord('.')): + elif self.freq_entry != 'None' and (keyb - 48 in range (10) or keyb == ord('.')): # build up frequency from 1-9 and '.' self.freq_entry = self.freq_entry + chr(keyb) return False @@ -497,52 +636,52 @@ def proc_keyb_soft(self, keyb): # pylint: disable=too-many-return-statements # pylint: disable=too-many-branches - # Tune self.gain_db in 10 dB steps with 'g' and 'f' + # Tune 1st gain element in 10 dB steps with 'g' and 'f' if keyb == ord('g'): - self.gain_db += 10 + self.gains[0]["value"] += 10 return True elif keyb == ord('f'): - self.gain_db -= 10 + self.gains[0]["value"] -= 10 return True - # Tune self.gain_db in 1 dB steps with 'G' and 'F' + # Tune 1st gain element in 1 dB steps with 'G' and 'F' if keyb == ord('G'): - self.gain_db += 1 + self.gains[0]["value"] += 1 return True elif keyb == ord('F'): - self.gain_db -= 1 + self.gains[0]["value"] -= 1 return True - # Tune self.if_gain_db in 10 dB steps with 'u' and 'y' + # Tune 2nd gain element in 10 dB steps with 'u' and 'y' if keyb == ord('u'): - self.if_gain_db += 10 + self.gains[1]["value"] += 10 return True elif keyb == ord('y'): - self.if_gain_db -= 10 + self.gains[1]["value"] -= 10 return True - # Tune self.if_gain_db in 1 dB steps with 'U' and 'Y' + # Tune 2nd gain element in 1 dB steps with 'U' and 'Y' if keyb == ord('U'): - self.if_gain_db += 1 + self.gains[1]["value"] += 1 return True elif keyb == ord('Y'): - self.if_gain_db -= 1 + self.gains[1]["value"] -= 1 return True - # Tune self.bb_gain_db in 10 dB steps with ']' and '[' + # Tune 3rd gain element in 10 dB steps with ']' and '[' if keyb == ord(']'): - self.bb_gain_db += 10 + self.gains[2]["value"] += 10 return True elif keyb == ord('['): - self.bb_gain_db -= 10 + self.gains[2]["value"] -= 10 return True - # Tune self.bb_gain_db in 1 dB steps with '}' and '{' + # Tune 3rd gain element in 1 dB steps with '}' and '{' if keyb == ord('}'): - self.bb_gain_db += 1 + self.gains[2]["value"] += 1 return True elif keyb == ord('{'): - self.bb_gain_db -= 1 + self.gains[2]["value"] -= 1 return True # Tune self.squelch_db in 1 dB steps with 's' and 'a' @@ -568,16 +707,28 @@ def setup_screen(screen): # Set screen to getch() is non-blocking screen.nodelay(1) + # hide cursor + curses.curs_set(0) + + # do not echo keystrokes + curses.noecho() + + # break on ctrl-c + curses.cbreak() + # Define some colors curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK) curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK) curses.init_pair(3, curses.COLOR_CYAN, curses.COLOR_BLACK) curses.init_pair(4, curses.COLOR_MAGENTA, curses.COLOR_BLACK) curses.init_pair(5, curses.COLOR_YELLOW, curses.COLOR_BLACK) + curses.init_pair(6, curses.COLOR_WHITE, curses.COLOR_BLACK) + curses.init_pair(7, curses.COLOR_BLUE, curses.COLOR_BLACK) # Add border screen.border(0) + def main(): """Test most of the GUI (except lockout processing) @@ -598,7 +749,8 @@ def main(): # Setup the screen setup_screen(screen) - # Create windows + +# Create windows specwin = SpectrumWindow(screen) chanwin = ChannelWindow(screen) lockoutwin = LockoutWindow(screen) @@ -631,6 +783,9 @@ def main(): rxwin.proc_keyb_hard(keyb) rxwin.proc_keyb_soft(keyb) + if keyb == ord('Q'): + break + # Sleep to get about a 10 Hz refresh time.sleep(0.1) diff --git a/apps/errors.py b/apps/errors.py new file mode 100644 index 0000000..4a16dcf --- /dev/null +++ b/apps/errors.py @@ -0,0 +1,16 @@ +class Error(Exception): + """Base class for exceptions in this module.""" + pass + +class LogError(Error): + """Exception raised for errors in the log system. + + Attributes: + expression -- input expression in which the error occurred + message -- explanation of the error + """ + + def __init__(self, expression, message): + self.expression = expression + self.message = message + diff --git a/apps/estimate.py b/apps/estimate.py index 1e597df..f61e96e 100755 --- a/apps/estimate.py +++ b/apps/estimate.py @@ -68,30 +68,30 @@ def main(): """ Tests the functions in this module""" # Test avg_freq() - print "Testing avg_freq()" + print("Testing avg_freq()") data = np.array([0, 1, 1, 0]) - print "Input spectrum data is " + str(data) + print("Input spectrum data is " + str(data)) result = avg_freq(data) - print "Average frequency is " + str(result) + print("Average frequency is " + str(result)) if result == 1.5: - print "Test Pass" + print("Test Pass") else: - print "Test Fail" - print "" + print("Test Fail") + print("") # Test channel_estimate() - print "Testing channel_estimate()" + print("Testing channel_estimate()") data = np.array([0, 1, 1, 0, 0, 1, 1, 1]) threshold = 0.5 - print "Input spectrum data is " + str(data) - print "Threshold is " + str(threshold) + print("Input spectrum data is " + str(data)) + print("Threshold is " + str(threshold)) result = channel_estimate(data, threshold) - print "Channels at " + str(result) + print("Channels at " + str(result)) if result == [1.5, 6.0]: - print "Test Pass" + print("Test Pass") else: - print "Test Fail" - print "" + print("Test Fail") + print("") if __name__ == '__main__': diff --git a/apps/ham2mon.py b/apps/ham2mon.py index 3e2d810..9b8d1d6 100755 --- a/apps/ham2mon.py +++ b/apps/ham2mon.py @@ -11,6 +11,25 @@ import cursesgui import parser import time +import errors as err +import traceback +import sys +import os +from datetime import datetime + +def print_custom_error_message(): + exc_type, exc_value, exc_tb = sys.exc_info() + stack_summary = traceback.extract_tb(exc_tb) + end = stack_summary[-1] + + err_type = type(exc_value).__name__ + err_msg = str(exc_value) + date = datetime.strftime(datetime.now(), "%B %d, %Y at precisely %I:%M %p") + + print(f"On {date}, a {err_type} occured in {end.filename} inside {end.name} on line {end.lineno} with the error message: {err_msg}.") + print(f"The following line of code is responsible: {end.line!r}") + print("Please make a note of it.") + print("") def main(screen): """Start scanner with GUI interface @@ -52,34 +71,54 @@ def main(screen): play = PARSER.play lockout_file_name = PARSER.lockout_file_name priority_file_name = PARSER.priority_file_name + channel_log_file_name = PARSER.channel_log_file_name + channel_log_timeout = PARSER.channel_log_timeout freq_correction = PARSER.freq_correction audio_bps = PARSER.audio_bps + max_demod_length = PARSER.max_demod_length + channel_spacing = PARSER.channel_spacing + min_file_size = PARSER.min_file_size + center_freq = PARSER.center_freq + freq_low = PARSER.freq_low + freq_high = PARSER.freq_high + scanner = scnr.Scanner(ask_samp_rate, num_demod, type_demod, hw_args, freq_correction, record, lockout_file_name, - priority_file_name, play, audio_bps) + priority_file_name, channel_log_file_name, channel_log_timeout, + play, audio_bps, max_demod_length, channel_spacing, min_file_size, + center_freq, freq_low, freq_high) # Set the paramaters - scanner.set_center_freq(PARSER.center_freq) - scanner.set_gain(PARSER.gain_db) - scanner.set_if_gain(PARSER.if_gain_db) - scanner.set_bb_gain(PARSER.bb_gain_db) + scanner.set_center_freq(center_freq) + scanner.set_squelch(PARSER.squelch_db) scanner.set_volume(PARSER.volume_db) scanner.set_threshold(PARSER.threshold_db) + rxwin.gains = scanner.filter_and_set_gains(PARSER.gains) + # Get the initial settings for GUI rxwin.center_freq = scanner.center_freq + rxwin.min_freq = scanner.min_freq + rxwin.max_freq = scanner.max_freq + rxwin.freq_low = scanner.freq_low + rxwin.freq_high = scanner.freq_high rxwin.samp_rate = scanner.samp_rate - rxwin.gain_db = scanner.gain_db - rxwin.if_gain_db = scanner.if_gain_db - rxwin.bb_gain_db = scanner.bb_gain_db rxwin.squelch_db = scanner.squelch_db rxwin.volume_db = scanner.volume_db rxwin.record = scanner.record rxwin.type_demod = type_demod rxwin.lockout_file_name = scanner.lockout_file_name rxwin.priority_file_name = scanner.priority_file_name - + rxwin.channel_log_file_name = scanner.channel_log_file_name + rxwin.channel_log_timeout = scanner.channel_log_timeout + if (rxwin.channel_log_file_name != ""): + rxwin.log_mode = "file" + else: + rxwin.log_mode = "none" + + specwin.max_db = PARSER.max_db + specwin.min_db = PARSER.min_db specwin.threshold_db = scanner.threshold_db while 1: @@ -91,8 +130,8 @@ def main(screen): # Update the spectrum, channel, and rx displays specwin.draw_spectrum(scanner.spectrum) - chanwin.draw_channels(scanner.gui_tuned_channels) - lockoutwin.draw_channels(scanner.gui_lockout_channels) + chanwin.draw_channels(scanner.gui_tuned_channels, scanner.gui_active_channels) + lockoutwin.draw_channels(scanner.gui_lockout_channels, scanner.gui_active_channels) rxwin.draw_rx() # Update physical screen @@ -101,6 +140,9 @@ def main(screen): # Get keystroke keyb = screen.getch() + if keyb == ord('Q'): + break + # Send keystroke to spectrum window and update scanner if True if specwin.proc_keyb(keyb): scanner.set_threshold(specwin.threshold_db) @@ -110,17 +152,12 @@ def main(screen): # Set and update frequency scanner.set_center_freq(rxwin.center_freq) rxwin.center_freq = scanner.center_freq + rxwin.min_freq = scanner.min_freq + rxwin.max_freq = scanner.max_freq if rxwin.proc_keyb_soft(keyb): - # Set and update RF gain - scanner.set_gain(rxwin.gain_db) - rxwin.gain_db = scanner.gain_db - # Set and update IF gain - scanner.set_if_gain(rxwin.if_gain_db) - rxwin.if_gain_db = scanner.if_gain_db - # Set and update BB gain - scanner.set_bb_gain(rxwin.bb_gain_db) - rxwin.bb_gain_db = scanner.bb_gain_db + # Set all the gains + rxwin.gains = scanner.filter_and_set_gains(rxwin.gains) # Set and update squelch scanner.set_squelch(rxwin.squelch_db) rxwin.squelch_db = scanner.squelch_db @@ -136,19 +173,74 @@ def main(screen): if lockoutwin.proc_keyb_clear_lockout(keyb): scanner.clear_lockout() + # cleanup terminating all demodulators + for demod in scanner.receiver.demodulators: + demod.set_tuner_freq(0, 0); + if __name__ == '__main__': try: # Do this since curses wrapper won't let parser write to screen PARSER = parser.CLParser() if len(PARSER.parser_args) != 0: PARSER.print_help() #pylint: disable=maybe-no-member - raise SystemExit, 1 + raise(SystemExit, 1) else: curses.wrapper(main) except KeyboardInterrupt: pass - except RuntimeError: - print "" - print "RuntimeError: SDR hardware not detected or insufficient USB permissions. Try running as root." - print "" + except RuntimeError as err: + print("") + print("RuntimeError: SDR hardware not detected or insufficient USB permissions. Try running as root.") + print("") + print("RuntimeError: {err=}, {type(err)=}") + print("") + print(traceback.format_exc) + print(sys.exc_info()[2]) + print("") + + print_custom_error_message() + + except err.LogError: + print("") + print("LogError: database logging not active, to be expanded.") + print("") + print(traceback.format_exc) + print(sys.exc_info()[2]) + print("") + + print_custom_error_message() + + except OSError as err: + print("") + print("OS error: {0}".format(err)) + print("") + print(traceback.format_exc) + print(sys.exc_info()[2]) + print("") + + print_custom_error_message() + + #exc_type, exc_obj, exc_tb = sys.exc_info() + #fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + #print(exc_type, fname, exc_tb.tb_lineno) + + except BaseException as err: + print("") + print("Unexpected: {err=}, {type(err)=}", err, type(err)) + print("") + print(traceback.format_exc) + print(sys.exc_info()[2]) + print("") + + print_custom_error_message() + + #exc_type, exc_obj, exc_tb = sys.exc_info() + #fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + #print(exc_type, fname, exc_tb.tb_lineno) + + finally: + # --- Cleanup on exit --- + curses.echo() + curses.nocbreak() + curses.endwin() diff --git a/apps/lockout-example.txt b/apps/lockout-example.txt new file mode 100644 index 0000000..d4ce1cb --- /dev/null +++ b/apps/lockout-example.txt @@ -0,0 +1,6 @@ +144275000 +144280000 +144285000 +144290000 +144295000 +144300000 diff --git a/apps/nbfm_flow_example.py b/apps/nbfm_flow_example.py index 085c36e..08b4504 100755 --- a/apps/nbfm_flow_example.py +++ b/apps/nbfm_flow_example.py @@ -14,7 +14,7 @@ x11 = ctypes.cdll.LoadLibrary('libX11.so') x11.XInitThreads() except: - print "Warning: failed to XInitThreads()" + print("Warning: failed to XInitThreads()") from PyQt4 import Qt from gnuradio import analog diff --git a/apps/parser.py b/apps/parser.py index 43830cf..41c5920 100755 --- a/apps/parser.py +++ b/apps/parser.py @@ -17,7 +17,7 @@ class CLParser(object): num_demod (int): Number of parallel demodulators center_freq (float): Hardware RF center frequency in Hz ask_samp_rate (float): Asking sample rate of hardware in sps (1E6 min) - gain_db (int): Hardware RF gain in dB + gains : Enumerated gain types and values squelch_db (int): Squelch in dB volume_dB (int): Volume in dB threshold_dB (int): Threshold for channel detection in dB @@ -25,8 +25,17 @@ class CLParser(object): play (bool): Play audio through speaker if True lockout_file_name (string): Name of file with channels to lockout priority_file_name (string): Name of file with channels to for priority + channel_log_file_name (string): Name of file for channel logging + channel_log_timeout (int): Timeout delay between active channel log entries freq_correction (int): Frequency correction in ppm audio_bps (int): Audio bit depth in bps + max_db (float): Spectrum max dB for display + min_db (float): Spectrum min dB for display + max_demod_length (int): Timeout for long running demodulators to reset new file timestamp in seconds + channel_spacing (int): Channel spacing (spectrum bin size) for identification of channels + min_file_size (int): Minimum file size to save + freq_low (int): Low frequency for channels + freq_high (int): High frequency for channels """ # pylint: disable=too-few-public-methods # pylint: disable=too-many-instance-attributes @@ -48,6 +57,10 @@ def __init__(self): default=0, help="Type of demodulator (0=NBFM, 1=AM)") + parser.add_option("-e", "--range", type="string", + dest="freq_range", default="0-2000000000", + help="Limit reception to specified frequency range") + parser.add_option("-f", "--freq", type="string", dest="center_freq", default=146E6, help="Hardware RF center frequency in Hz") @@ -56,15 +69,21 @@ def __init__(self): default=4E6, help="Hardware ask sample rate in sps (1E6 minimum)") - parser.add_option("-g", "--gain", type="eng_float", dest="gain_db", + parser.add_option("-g", "--gain", type="eng_float", dest="rf_gain_db", default=0, help="Hardware RF gain in dB") parser.add_option("-i", "--if_gain", type="eng_float", dest="if_gain_db", - default=16, help="Hardware IF gain in dB") + default=16, help="Hardware IF gain in dB or index (driver dependent)") parser.add_option("-o", "--bb_gain", type="eng_float", dest="bb_gain_db", default=16, help="Hardware BB gain in dB") + parser.add_option("-j", "--lna_gain", type="eng_float", dest="lna_gain_db", + default=8, help="Hardware LNA gain index") + + parser.add_option("-x", "--mix_gain", type="eng_float", dest="mix_gain_db", + default=5, help="Hardware MIX gain index") + parser.add_option("-s", "--squelch", type="eng_float", dest="squelch_db", default=-60, help="Squelch in dB") @@ -91,6 +110,16 @@ def __init__(self): default="", help="File of EOL delimited priority channels in Hz") + parser.add_option("-L", "--log_file", type="string", + dest="channel_log_file_name", + default="channel-log", + help="Log file for channel detection") + + parser.add_option("-A", "--log_active_timeout", type="int", + dest="channel_log_timeout", + default=15, + help="Timeout delay for active channel log entries") + parser.add_option("-c", "--correction", type="int", dest="freq_correction", default=0, help="Frequency correction in ppm") @@ -100,8 +129,28 @@ def __init__(self): help="Mute audio from speaker (still allows recording)") parser.add_option("-b", "--bps", type="int", dest="audio_bps", - default=8, + default=16, help="Audio bit depth (bps)") + + parser.add_option("-M", "--max_db", type="float", dest="max_db", + default=50, + help="Spectrum window max dB for display") + + parser.add_option("-N", "--min_db", type="float", dest="min_db", + default=-10, + help="Spectrum window min dB for display (no greater than -10dB from max") + + parser.add_option("-k", "--max-demod-length", type="int", dest="max_demod_length", + default=0, + help="Maxumum length for a demodulation (sec)") + + parser.add_option("-B", "--channel-spacing", type="int", dest="channel_spacing", + default=5000, + help="Channel spacing (spectrum bin size)") + + parser.add_option("-F", "--min-file-size", type="int", dest="min_file_size", + default=0, + help="Minimum size file to save in bytes, default 0 (save all)") options = parser.parse_args()[0] self.parser_args = parser.parse_args()[1] @@ -111,9 +160,15 @@ def __init__(self): self.type_demod = int(options.type_demod) self.center_freq = float(options.center_freq) self.ask_samp_rate = float(options.ask_samp_rate) - self.gain_db = float(options.gain_db) - self.if_gain_db = float(options.if_gain_db) - self.bb_gain_db = float(options.bb_gain_db) + + self.gains = [ + { "name": "RF", "value": float(options.rf_gain_db), "query": "yes" }, + { "name": "LNA","value": float(options.lna_gain_db), "query": "no" }, + { "name": "MIX","value": float(options.mix_gain_db), "query": "no" }, + { "name": "IF", "value": float(options.if_gain_db), "query": "no" }, + { "name": "BB", "value": float(options.bb_gain_db), "query": "no" } + ] + self.squelch_db = float(options.squelch_db) self.volume_db = float(options.volume_db) self.threshold_db = float(options.threshold_db) @@ -121,9 +176,25 @@ def __init__(self): self.play = bool(options.play) self.lockout_file_name = str(options.lockout_file_name) self.priority_file_name = str(options.priority_file_name) + self.channel_log_file_name = str(options.channel_log_file_name) + self.channel_log_timeout = int(options.channel_log_timeout) self.freq_correction = int(options.freq_correction) self.audio_bps = int(options.audio_bps) - + self.max_db = float(options.max_db) + self.min_db = float(options.min_db) + self.max_demod_length = int(options.max_demod_length) + self.channel_spacing = int(options.channel_spacing) + self.min_file_size = int(options.min_file_size) + + try: + self.freq_low = int(options.freq_range.split('-')[0]) + except: + self.freq_low = 0 + + try: + self.freq_high = int(options.freq_range.split('-')[1]) + except: + self.freq_high = 0 def main(): """Test the parser""" @@ -132,24 +203,33 @@ def main(): if len(parser.parser_args) != 0: parser.print_help() #pylint: disable=maybe-no-member - raise SystemExit, 1 - - print "hw_args: " + parser.hw_args - print "num_demod: " + str(parser.num_demod) - print "type_demod: " + str(parser.type_demod) - print "center_freq: " + str(parser.center_freq) - print "ask_samp_rate: " + str(parser.ask_samp_rate) - print "gain_db: " + str(parser.gain_db) - print "if_gain_db: " + str(parser.if_gain_db) - print "bb_gain_db: " + str(parser.bb_gain_db) - print "squelch_db: " + str(parser.squelch_db) - print "volume_db: " + str(parser.volume_db) - print "threshold_db: " + str(parser.threshold_db) - print "record: " + str(parser.record) - print "lockout_file_name: " + str(parser.lockout_file_name) - print "priority_file_name: " + str(parser.priority_file_name) - print "freq_correction: " + str(parser.freq_correction) - print "audio_bps: " + str(parser.audio_bps) + raise(SystemExit, 1) + + print("hw_args: " + parser.hw_args) + print("num_demod: " + str(parser.num_demod)) + print("type_demod: " + str(parser.type_demod)) + print("center_freq: " + str(parser.center_freq)) + print("ask_samp_rate: " + str(parser.ask_samp_rate)) + for gain in parser.gains: + print("gain %s at %d dB" % (gain["name"], gain["value"])) + print("squelch_db: " + str(parser.squelch_db)) + print("volume_db: " + str(parser.volume_db)) + print("threshold_db: " + str(parser.threshold_db)) + print("record: " + str(parser.record)) + print("play: " + str(parser.play)) + print("lockout_file_name: " + str(parser.lockout_file_name)) + print("priority_file_name: " + str(parser.priority_file_name)) + print("channel_log_file_name: " + str(parser.channel_log_file_name)) + print("channel_log_timeout: " + str(parser.channel_log_timeout)) + print("freq_correction: " + str(parser.freq_correction)) + print("audio_bps: " + str(parser.audio_bps)) + print("max_db: " + str(parser.max_db)) + print("min_db: " + str(parser.min_db)) + print("max_demod_length: " + str(parser.max_demod_length)) + print("channel_spacing: " + str(parser.channel_spacing)) + print("min_file_size: " + str(parser.min_file_size)) + print("freq_low: " + str(parser.freq_low)) + print("freq_high: " + str(parser.freq_high)) if __name__ == '__main__': diff --git a/apps/priority-example.txt b/apps/priority-example.txt new file mode 100644 index 0000000..f8d1b25 --- /dev/null +++ b/apps/priority-example.txt @@ -0,0 +1 @@ +146520000 diff --git a/apps/priority-frs1 b/apps/priority-frs1 new file mode 100644 index 0000000..1bdfdb5 --- /dev/null +++ b/apps/priority-frs1 @@ -0,0 +1,8 @@ +462562500 +462587500 +462612500 +462637500 +462662500 +462687500 +462712500 + diff --git a/apps/priority-frs2 b/apps/priority-frs2 new file mode 100644 index 0000000..29676d2 --- /dev/null +++ b/apps/priority-frs2 @@ -0,0 +1,8 @@ +467562500 +467587500 +467612500 +467637500 +467662500 +467687500 +467712500 + diff --git a/apps/priority-gmrs b/apps/priority-gmrs new file mode 100644 index 0000000..482c457 --- /dev/null +++ b/apps/priority-gmrs @@ -0,0 +1,8 @@ +462550000 +462575000 +462600000 +462625000 +462650000 +462675000 +462700000 +462725000 diff --git a/apps/priority-gmrsri b/apps/priority-gmrsri new file mode 100644 index 0000000..3d22f38 --- /dev/null +++ b/apps/priority-gmrsri @@ -0,0 +1,8 @@ +467550000 +467575000 +467600000 +467625000 +467650000 +467675000 +467700000 +467725000 diff --git a/apps/priority-uhf b/apps/priority-uhf new file mode 100644 index 0000000..9183cf5 --- /dev/null +++ b/apps/priority-uhf @@ -0,0 +1,2 @@ +444000000 +446000000 diff --git a/apps/priority-vhf b/apps/priority-vhf new file mode 100644 index 0000000..ab6b77e --- /dev/null +++ b/apps/priority-vhf @@ -0,0 +1,2 @@ +146520000 +146900000 diff --git a/apps/receiver.py b/apps/receiver.py index be6ba2c..e066893 100755 --- a/apps/receiver.py +++ b/apps/receiver.py @@ -25,57 +25,81 @@ class BaseTuner(gr.hier_block2): See TunerDemodNBFM and TunerDemodAM for better documentation. """ - def set_center_freq(self, center_freq, rf_center_freq): + def __init__(self): + # Default values + self.last_heard = 0 + self.file_name = None + + def get_last_heard(): + return self.last_heard + + def set_last_heard(self, a_time): + self.last_heard = a_time + + def set_tuner_freq(self, tuner_freq, rf_center_freq): """Sets baseband center frequency and file name Sets baseband center frequency of frequency translating FIR filter Also sets file name of wave file sink - If tuner is tuned to zero Hz then set to file name to /dev/null + If tuner is tuned to zero Hz then set to file name to None Otherwise set file name to tuned RF frequency in MHz Args: - center_freq (float): Baseband center frequency in Hz + tuner_freq (float): Baseband tuner frequency in Hz rf_center_freq (float): RF center in Hz (for file name) """ # Since the frequency (hence file name) changed, then close it - self.blocks_wavfile_sink.close() + if (self.record and self.file_name and + self.file_name != None): + self.blocks_wavfile_sink.close() - # If we never wrote any data to the wavfile sink, delete the file + # If we did not write enough data to the wavfile sink, delete the file self._delete_wavfile_if_empty() - # Set the frequency - self.freq_xlating_fir_filter_ccc.set_center_freq(center_freq) - self.center_freq = center_freq + # Set the frequency of the tuner + self.tuner_freq = tuner_freq + self.rf_center_freq = rf_center_freq + self.freq_xlating_fir_filter_ccc.set_center_freq(self.tuner_freq) - # Set the file name - if self.center_freq == 0 or not self.record: - # If tuner at zero Hz, or record false, then file name to /dev/null - file_name = "/dev/null" + # Set the file name if recording + if self.tuner_freq == 0 or not self.record: + # If tuner at zero Hz, or record false, then file name to None + self.file_name = None else: - # Otherwise use frequency and time stamp for file name - tstamp = "_" + str(int(time.time())) - file_freq = (rf_center_freq + self.center_freq)/1E6 - file_freq = np.round(file_freq, 3) - file_name = 'wav/' + '{:.3f}'.format(file_freq) + tstamp + ".wav" - - # Make sure the 'wav' directory exists - try: - os.mkdir('wav') - except OSError: # will need to add something here for Win support - pass # directory already exists + self.set_file_name(rf_center_freq) + + if (self.file_name != None and self.record): + self.blocks_wavfile_sink.open(self.file_name) + + def set_file_name(self, rf_center_freq): + self.rf_center_freq = rf_center_freq + # Otherwise use frequency and time stamp for file name + timestamp = int(time.time()) + tstamp = time.strftime("%Y%m%d_%H%M%S", time.localtime()) + "{:.3f}".format(time.time()%1)[1:] + file_freq = (self.rf_center_freq + self.tuner_freq)/1E6 + file_freq = np.round(file_freq, 4) + file_name = 'wav/' + '{:.4f}'.format(file_freq) + "_" + tstamp + ".wav" + + # Make sure the 'wav' directory exists + # a better approach likely is checking existence instead of failing to create it + try: + os.mkdir('wav') + except OSError: # will need to add something here for Win support + pass # directory already exists self.file_name = file_name - self.blocks_wavfile_sink.open(self.file_name) + # timestamp the demod update to match filename + self.time_stamp = timestamp def _delete_wavfile_if_empty(self): """Delete the current wavfile if it's empty.""" if (not self.record or not self.file_name or - self.file_name == '/dev/null'): + self.file_name == None): return - # If we never wrote any data to the wavfile sink, delete - # the (empty) wavfile - if os.stat(self.file_name).st_size in (44, 0): # ugly hack + # If we never wrote any data to the wavfile sink, or its smaller than + # the minimum defined size, then delete the (empty) wavfile + if os.stat(self.file_name).st_size < (self.min_file_size): os.unlink(self.file_name) # delete the file def set_squelch(self, squelch_db): @@ -125,39 +149,45 @@ class TunerDemodNBFM(BaseTuner): audio_rate (float): Output audio sample rate in sps (8 kHz minimum) record (bool): Record audio to file if True audio_bps (int): Audio bit depth in bps (bits/samples) + min_file_size (int): Minimum saved wav file size Attributes: center_freq (float): Baseband center frequency in Hz record (bool): Record audio to file if True + time_stamp (int): Time stamp of demodulator start for timing run length """ # pylint: disable=too-many-instance-attributes def __init__(self, samp_rate=4E6, audio_rate=8000, record=True, - audio_bps=8): + audio_bps=8, min_file_size=0): gr.hier_block2.__init__(self, "TunerDemodNBFM", gr.io_signature(1, 1, gr.sizeof_gr_complex), gr.io_signature(1, 1, gr.sizeof_float)) # Default values - self.center_freq = 0 + self.tuner_freq = 0 + self.rf_center_freq = 0 + self.time_stamp = 0 squelch_db = -60 self.quad_demod_gain = 0.050 - self.file_name = "/dev/null" + self.file_name = None self.record = record + self.min_file_size = min_file_size + self.last_heard = 0 # Decimation values for four stages of decimation decims = (5, int(samp_rate/1E6)) # Low pass filter taps for decimation by 5 low_pass_filter_taps_0 = \ - grfilter.firdes_low_pass(1, 1, 0.090, 0.010, - grfilter.firdes.WIN_HAMMING) + grfilter.firdes.low_pass(1, 1, 0.090, 0.010, + window.WIN_HAMMING) # Frequency translating FIR filter decimating by 5 self.freq_xlating_fir_filter_ccc = \ grfilter.freq_xlating_fir_filter_ccc(decims[0], low_pass_filter_taps_0, - self.center_freq, samp_rate) + self.tuner_freq, samp_rate) # FIR filter decimating by 5 fir_filter_ccc_0 = grfilter.fir_filter_ccc(decims[0], @@ -166,8 +196,8 @@ def __init__(self, samp_rate=4E6, audio_rate=8000, record=True, # Low pass filter taps for decimation from samp_rate/25 to 40-79.9 ksps # In other words, decimation by int(samp_rate/1E6) # 12.5 kHz cutoff for NBFM channel bandwidth - low_pass_filter_taps_1 = grfilter.firdes_low_pass( - 1, samp_rate/decims[0]**2, 12.5E3, 1E3, grfilter.firdes.WIN_HAMMING) + low_pass_filter_taps_1 = grfilter.firdes.low_pass( + 1, samp_rate/decims[0]**2, 12.5E3, 1E3, window.WIN_HAMMING) # FIR filter decimation by int(samp_rate/1E6) fir_filter_ccc_1 = grfilter.fir_filter_ccc(decims[1], @@ -183,9 +213,9 @@ def __init__(self, samp_rate=4E6, audio_rate=8000, record=True, analog.quadrature_demod_cf(self.quad_demod_gain) # 3.5 kHz cutoff for audio bandwidth - low_pass_filter_taps_2 = grfilter.firdes_low_pass(1,\ + low_pass_filter_taps_2 = grfilter.firdes.low_pass(1,\ samp_rate/(decims[1] * decims[0]**2),\ - 3.5E3, 500, grfilter.firdes.WIN_HAMMING) + 3.5E3, 500, window.WIN_HAMMING) # FIR filter decimating by 5 from 40-79.9 ksps to 8-15.98 ksps fir_filter_fff_0 = grfilter.fir_filter_fff(decims[0], @@ -212,13 +242,21 @@ def __init__(self, samp_rate=4E6, audio_rate=8000, record=True, # Only want it to gate when the previous squelch has gone to zero analog_pwr_squelch_ff = analog.pwr_squelch_ff(-200, 1e-1, 0, True) - # File sink with single channel and bits/sample - self.blocks_wavfile_sink = blocks.wavfile_sink(self.file_name, 1, - audio_rate, audio_bps) - # Connect the blocks for recording self.connect(pfb_arb_resampler_fff, analog_pwr_squelch_ff) - self.connect(analog_pwr_squelch_ff, self.blocks_wavfile_sink) + + # File sink with single channel and bits/sample + if (self.record): + self.set_file_name(self.tuner_freq) + self.blocks_wavfile_sink = blocks.wavfile_sink(self.file_name, 1, + audio_rate, + blocks.FORMAT_WAV, + blocks.FORMAT_PCM_16, + False) + self.connect(analog_pwr_squelch_ff, self.blocks_wavfile_sink) + else: + null_sink1 = blocks.null_sink(gr.sizeof_float) + self.connect(analog_pwr_squelch_ff, null_sink1) def set_volume(self, volume_db): """Sets the volume @@ -261,40 +299,48 @@ class TunerDemodAM(BaseTuner): audio_rate (float): Output audio sample rate in sps (8 kHz minimum) record (bool): Record audio to file if True audio_bps (int): Audio bit depth in bps (bits/samples) + min_file_size (int): Minimum saved wav file size Attributes: - center_freq (float): Baseband center frequency in Hz + tuner_freq (float): Baseband center frequency in Hz + rf_center_freq (float): RF center frequency in Hz record (bool): Record audio to file if True + time_stamp (int): Time stamp of demodulator start for timing run length """ # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-locals def __init__(self, samp_rate=4E6, audio_rate=8000, record=True, - audio_bps=8): + audio_bps=16): gr.hier_block2.__init__(self, "TunerDemodAM", gr.io_signature(1, 1, gr.sizeof_gr_complex), gr.io_signature(1, 1, gr.sizeof_float)) # Default values - self.center_freq = 0 + self.tuner_freq = 0 + self.rf_center_freq = 0 + self.time_stamp = 0 squelch_db = -60 self.agc_ref = 0.1 - self.file_name = "/dev/null" + self.file_name = None self.record = record + self.min_file_size = min_file_size + self.last_heard = 0 + # Decimation values for four stages of decimation decims = (5, int(samp_rate/1E6)) # Low pass filter taps for decimation by 5 low_pass_filter_taps_0 = \ - grfilter.firdes_low_pass(1, 1, 0.090, 0.010, - grfilter.firdes.WIN_HAMMING) + grfilter.firdes.low_pass(1, 1, 0.090, 0.010, + window.WIN_HAMMING) # Frequency translating FIR filter decimating by 5 self.freq_xlating_fir_filter_ccc = \ grfilter.freq_xlating_fir_filter_ccc(decims[0], low_pass_filter_taps_0, - self.center_freq, samp_rate) + self.tuner_freq, samp_rate) # FIR filter decimating by 5 fir_filter_ccc_0 = grfilter.fir_filter_ccc(decims[0], @@ -303,8 +349,8 @@ def __init__(self, samp_rate=4E6, audio_rate=8000, record=True, # Low pass filter taps for decimation from samp_rate/25 to 40-79.9 ksps # In other words, decimation by int(samp_rate/1E6) # 12.5 kHz cutoff for NBFM channel bandwidth - low_pass_filter_taps_1 = grfilter.firdes_low_pass( - 1, samp_rate/decims[0]**2, 12.5E3, 1E3, grfilter.firdes.WIN_HAMMING) + low_pass_filter_taps_1 = grfilter.firdes.low_pass( + 1, samp_rate/decims[0]**2, 12.5E3, 1E3, window.WIN_HAMMING) # FIR filter decimation by int(samp_rate/1E6) fir_filter_ccc_1 = grfilter.fir_filter_ccc(decims[1], @@ -325,9 +371,9 @@ def __init__(self, samp_rate=4E6, audio_rate=8000, record=True, am_demod_cf = blocks.complex_to_mag(1) # 3.5 kHz cutoff for audio bandwidth - low_pass_filter_taps_2 = grfilter.firdes_low_pass(1,\ + low_pass_filter_taps_2 = grfilter.firdes.low_pass(1,\ samp_rate/(decims[1] * decims[0]**2),\ - 3.5E3, 500, grfilter.firdes.WIN_HAMMING) + 3.5E3, 500, window.WIN_HAMMING) # FIR filter decimating by 5 from 40-79.9 ksps to 8-15.98 ksps fir_filter_fff_0 = grfilter.fir_filter_fff(decims[0], @@ -354,13 +400,21 @@ def __init__(self, samp_rate=4E6, audio_rate=8000, record=True, # Only want it to gate when the previous squelch has gone to zero analog_pwr_squelch_ff = analog.pwr_squelch_ff(-200, 1e-1, 0, True) - # File sink with single channel and 8 bits/sample - self.blocks_wavfile_sink = blocks.wavfile_sink(self.file_name, 1, - audio_rate, audio_bps) - # Connect the blocks for recording self.connect(pfb_arb_resampler_fff, analog_pwr_squelch_ff) - self.connect(analog_pwr_squelch_ff, self.blocks_wavfile_sink) + + # File sink with single channel and 8 bits/sample + if (self.record): + self.set_file_name(self.tuner_freq, self.rf_center_freq) + self.blocks_wavfile_sink = blocks.wavfile_sink(self.file_name, 1, + audio_rate, + blocks.FORMAT_WAV, + blocks.FORMAT_PCM_16, + False) + self.connect(analog_pwr_squelch_ff, self.blocks_wavfile_sink) + else: + null_sink1 = blocks.null_sink(gr.sizeof_float) + self.connect(analog_pwr_squelch_ff, null_sink1) def set_volume(self, volume_db): """Sets the volume @@ -399,26 +453,22 @@ class Receiver(gr.top_block): def __init__(self, ask_samp_rate=4E6, num_demod=4, type_demod=0, hw_args="uhd", freq_correction=0, record=True, play=True, - audio_bps=8): + audio_bps=16, min_file_size=0): # Call the initialization method from the parent class gr.top_block.__init__(self, "Receiver") # Default values - self.center_freq = 144E6 + self.center_freq = 146E6 self.gain_db = 0 - self.if_gain_db = 16 - self.bb_gain_db = 16 self.squelch_db = -60 self.volume_db = 0 audio_rate = 8000 + self.min_file_size = min_file_size # Setup the USRP source, or use the USRP sim self.src = osmosdr.source(args="numchan=" + str(1) + " " + hw_args) self.src.set_sample_rate(ask_samp_rate) - self.src.set_gain(self.gain_db) - self.src.set_if_gain(self.if_gain_db) - self.src.set_bb_gain(self.bb_gain_db) self.src.set_center_freq(self.center_freq) self.src.set_freq_corr(freq_correction) @@ -477,11 +527,11 @@ def __init__(self, ask_samp_rate=4E6, num_demod=4, type_demod=0, if type_demod == 1: self.demodulators.append(TunerDemodAM(self.samp_rate, audio_rate, record, - audio_bps)) + audio_bps, self.min_file_size)) else: self.demodulators.append(TunerDemodNBFM(self.samp_rate, audio_rate, record, - audio_bps)) + audio_bps, self.min_file_size)) if play: # Create an adder @@ -514,32 +564,34 @@ def set_center_freq(self, center_freq): # Do this to account for slight hardware offsets self.center_freq = self.src.get_center_freq() - def set_gain(self, gain_db): - """Sets gain of RF hardware - - Args: - gain_db (float): Hardware RF gain in dB + def get_gain_names(self): + """Get the list of suppoted gain elements """ - self.src.set_gain(gain_db) - self.gain_db = self.src.get_gain() - - def set_if_gain(self, if_gain_db): - """Sets IF gain of RF hardware + return self.src.get_gain_names() + def filter_and_set_gains(self, all_gains): + """Remove unsupported gains and set them Args: - if_gain_db (float): Hardware IF gain in dB + all_gains (list of dictionary): Supported gains in dB """ - self.src.set_if_gain(if_gain_db) - self.if_gain_db = if_gain_db - - def set_bb_gain(self, bb_gain_db): - """Sets BB gain of RF hardware - + gains = [] + names = self.get_gain_names() + for gain in all_gains: + if gain["name"] in names: + gains.append(gain) + return self.set_gains(gains) + + def set_gains(self, gains): + """Set all the gains Args: - bb_gain_db (float): Hardware BB gain in dB + gains (list of dictionary): Supported gains in dB """ - self.src.set_bb_gain(bb_gain_db) - self.bb_gain_db = bb_gain_db + for gain in gains: + self.src.set_gain(gain["value"], gain["name"]) + if gain["query"] == "yes": + gain["value"] = self.src.get_gain(gain["name"]) + self.gains = gains + return self.gains def set_squelch(self, squelch_db): """Sets squelch of all demodulators and clamps range @@ -567,22 +619,22 @@ def get_demod_freqs(self): Returns: List[float]: List of baseband center frequencies in Hz """ - center_freqs = [] + tuner_freqs = [] for demodulator in self.demodulators: - center_freqs.append(demodulator.center_freq) - return center_freqs + tuner_freqs.append(demodulator.tuner_freq) + return tuner_freqs def main(): """Test the receiver - Sets up the hadrware + Sets up the hardware Tunes a couple of demodulators Prints the max power spectrum """ # Create receiver object - ask_samp_rate = 4E6 + ask_samp_rate = 2E6 num_demod = 4 type_demod = 0 hw_args = "uhd" @@ -590,8 +642,9 @@ def main(): record = False play = True audio_bps = 8 + min_file_size = 0 receiver = Receiver(ask_samp_rate, num_demod, type_demod, hw_args, - freq_correction, record, play, audio_bps) + freq_correction, record, play, audio_bps, min_file_size) # Start the receiver and wait for samples to accumulate receiver.start() @@ -600,15 +653,16 @@ def main(): # Set frequency, gain, squelch, and volume center_freq = 144.5E6 receiver.set_center_freq(center_freq) - receiver.set_gain(10) - print "\n" - print "Started %s at %.3f Msps" % (hw_args, receiver.samp_rate/1E6) - print "RX at %.3f MHz with %d dB gain" % (receiver.center_freq/1E6, - receiver.gain_db) + print("\n") + print("Started %s at %.3f Msps" % (hw_args, receiver.samp_rate/1E6)) + print("RX at %.3f MHz" % (receiver.center_freq/1E6)) + receiver.filter_and_set_gains(parser.gains) + for gain in receiver.gains: + print("gain %s at %d dB" % (gain["name"], gain["value"])) receiver.set_squelch(-60) receiver.set_volume(0) - print "%d demods of type %d at %d dB squelch and %d dB volume" % \ - (num_demod, type_demod, receiver.squelch_db, receiver.volume_db) + print("%d demods of type %d at %d dB squelch and %d dB volume" % \ + (num_demod, type_demod, receiver.squelch_db, receiver.volume_db)) # Create some baseband channels to tune based on 144 MHz center channels = np.zeros(num_demod) @@ -618,13 +672,13 @@ def main(): # Tune demodulators to baseband channels # If recording on, this creates empty wav file since manually tuning. for idx, demodulator in enumerate(receiver.demodulators): - demodulator.set_center_freq(channels[idx], center_freq) + demodulator.set_tuner_freq(channels[idx], center_freq) # Print demodulator info for idx, channel in enumerate(channels): - print "Tuned demod %d to %.3f MHz" % (idx, + print("Tuned demod %d to %.3f MHz" % (idx, (channel+receiver.center_freq) - /1E6) + /1E6)) while 1: # No need to go faster than 10 Hz rate of GNU Radio probe @@ -633,7 +687,7 @@ def main(): # Grab the FFT data and print max value spectrum = receiver.probe_signal_vf.level() - print "Max spectrum of %.3f" % (np.max(spectrum)) + print("Max spectrum of %.3f" % (np.max(spectrum))) # Stop the receiver receiver.stop() diff --git a/apps/scanner.py b/apps/scanner.py index 619f192..d90a370 100755 --- a/apps/scanner.py +++ b/apps/scanner.py @@ -5,13 +5,42 @@ @author: madengr """ -import __builtin__ + import receiver as recvr import estimate import parser as prsr import time import numpy as np import sys +import types +import datetime +import errors as err + +PY3 = sys.version_info[0] == 3 +PY2 = sys.version_info[0] == 2 + +if PY3: + import builtins + # list-producing versions of the major Python iterating functions + def lrange(*args, **kwargs): + return list(range(*args, **kwargs)) + + def lzip(*args, **kwargs): + return list(zip(*args, **kwargs)) + + def lmap(*args, **kwargs): + return list(map(*args, **kwargs)) + + def lfilter(*args, **kwargs): + return list(filter(*args, **kwargs)) +else: + import __builtin__ + # Python 2-builtin ranges produce lists + lrange = __builtin__.range + lzip = __builtin__.zip + lmap = __builtin__.map + lfilter = __builtin__.filter + class Scanner(object): """Scanner that controls receiver @@ -29,12 +58,23 @@ class Scanner(object): hw_args (string): Argument string to pass to harwdare freq_correction (int): Frequency correction in ppm record (bool): Record audio to file if True + lockout_file_name (string): Name of file with channels to lockout + priority_file_name (string): Name of file with channels for priority + channel_log_file_name (string): Name of file with channel log entries + channel_log_timeout (int): Timeout delay between active channel entries in log audio_bps (int): Audio bit depth in bps (bits/samples) + max_demod_length (int): Maximum demod time in seconds (0=disable) + center_freq (int): initial center frequency for receiver (Hz) + freq_low (int): Freq below which we won't tune a receiver (Hz) + freq_high (int): Freq above which we won't tune a receiver (Hz) + spacing (int): granularity of frequency quantization Attributes: center_freq (float): Hardware RF center frequency in Hz + low_bound (int): Freq below which we won't tune a receiver (Hz) + high_bound (int): Freq above which we won't tune a receiver (Hz) samp_rate (float): Hardware sample rate in sps (1E6 min) - gain_db (int): Hardware RF gain in dB + gains : Enumerated gain types and values squelch_db (int): Squelch in dB volume_dB (int): Volume in dB threshold_dB (int): Threshold for channel detection in dB @@ -42,50 +82,155 @@ class Scanner(object): lockout_channels [float]: List of baseband lockout channels in Hz priority_channels [float]: List of baseband priority channels in Hz gui_tuned_channels [str] List of tuned RF channels in MHz for GUI + gui_active_channels [str] List of active RF channels in MHz for GUI (currently above threshold) gui_tuned_lockout_channels [str]: List of lockout channels in MHz GUI channel_spacing (float): Spacing that channels will be rounded lockout_file_name (string): Name of file with channels to lockout priority_file_name (string): Name of file with channels for priority + channel_log_file_name (string): Name of file with channel log entries + channel_log_timeout (int): Timeout delay between active channel entries in log + log_timeout_last (int): Last timestamp when recently active channels were logged and cleared + max_demod_length (int): Maximum demod time in seconds (0=disable) """ # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-arguments def __init__(self, ask_samp_rate=4E6, num_demod=4, type_demod=0, hw_args="uhd", freq_correction=0, record=True, - lockout_file_name="", priority_file_name="", play=True, - audio_bps=8): + lockout_file_name="", priority_file_name="", + channel_log_file_name="", channel_log_timeout=15, + play=True, + audio_bps=8, max_demod_length=0, channel_spacing=5000, + min_file_size=0, center_freq=0, freq_low=0, freq_high=2000000000): # Default values - self.gain_db = 0 - self.if_gain_db = 16 - self.bb_gain_db = 16 self.squelch_db = -60 self.volume_db = 0 self.threshold_db = 10 self.record = record self.play = play + self.audio_bps = audio_bps + self.freq_low = freq_low + self.freq_high = freq_high + self.center_freq = center_freq self.spectrum = [] self.lockout_channels = [] self.priority_channels = [] + self.active_channels = [] + self.unordered_active_channels = [] + self.ordered_active_channels = [] + self.power_ordered_active_channels = [] + self.ordered_active_channels_freqs = [] self.gui_tuned_channels = [] + self.gui_active_channels = [] self.gui_lockout_channels = [] - self.channel_spacing = 5000 + self.channel_spacing = channel_spacing self.lockout_file_name = lockout_file_name self.priority_file_name = priority_file_name + self.channel_log_file_name = channel_log_file_name + self.channel_log_file = None + self.channel_log_timeout = channel_log_timeout + self.log_recent_channels = [] + self.log_timeout_last = int(time.time()) + self.log_mode = "" + self.max_demod_length = max_demod_length + self.min_file_size = min_file_size + self.low_bound = freq_low + self.high_bound = freq_high + self.hang_time = 1.0 # Create receiver object self.receiver = recvr.Receiver(ask_samp_rate, num_demod, type_demod, hw_args, freq_correction, record, play, - audio_bps) + audio_bps, + min_file_size) + + # Set the initial center frequency here to allow setting min/max and low/high bounds + self.receiver.set_center_freq(center_freq) + + # Open channel log file for appending data, if it is specified + if channel_log_file_name != "": + self.channel_log_file = open(channel_log_file_name, 'a') + if self.channel_log_file != None: + self.log_mode = "file" + else: + # Opening log file failed so cannot perform this log mode + # Either raise exception or continue without logging, second preferable + self.log_mode = "none" + #raise(LogError("file","Cannot open log file")) + else: + self.channel_log_file = None - # Get the hardware sample rate and center frequency + # Get the hardware sample rate and center frequency in Hz self.samp_rate = self.receiver.samp_rate self.center_freq = self.receiver.center_freq + self.min_freq = (self.center_freq - self.samp_rate/2) + self.max_freq = (self.center_freq + self.samp_rate/2) + # cannot set channel freq lower than min sampled freq + if (self.freq_low < self.min_freq): + self.freq_low = self.min_freq + # cannot set channel freq higher than max sampled freq + if (self.freq_high > self.max_freq): + self.freq_high = self.max_freq + self.low_bound = self.freq_low - self.center_freq + self.high_bound = self.freq_high - self.center_freq # Start the receiver and wait for samples to accumulate self.receiver.start() time.sleep(1) + if self.channel_log_file != None : + self.channel_log_file.flush() + + def __del__(self): + if self.channel_log_file != None : + self.channel_log_file.close() + + def __print_channel_log_active__(self, freq, state): + if self.log_mode is not None and self.log_mode != "none" and state is True: + state_str = {True: "act", False: "off"} + now = datetime.datetime.now() + if self.log_mode == "file" and self.channel_log_file is not None: + self.channel_log_file.write( + "{}: {:<4}{:>13}{:>7} dB {:>7} dB timeout {:>3}\n".format( + now.strftime("%Y-%m-%d, %H:%M:%S.%f"), + state_str[state], + freq, + 0, + #self.gain_db, + self.threshold_db, + self.channel_log_timeout)) + elif self.log_mode == "db": + # write log to db + raise(err.LogError("db","no db mode implemented")) + else: + # cannot log unknown mode + raise(err.LogError("unknown","no log mode defined")) + + def __print_channel_log__(self, freq, state, idx): + if self.log_mode is not None and self.log_mode != "none": + state_str = {True: "on", False: "off"} + if state == False: + freq = 0 + now = datetime.datetime.now() + if self.log_mode == "file" and self.channel_log_file is not None: + self.channel_log_file.write( + "{}: {:<4}{:>13}{:>7} dB {:>7} dB channel {:>3}\n".format( + now.strftime("%Y-%m-%d, %H:%M:%S.%f"), + state_str[state], + freq, + 0, + #self.gain_db, + self.threshold_db, + idx)) + elif self.log_mode == "db": + # write log to db + raise(err.LogError("db","no db mode implemented")) + else: + # cannot log unknown mode + raise(err.LogError("unknown","no log mode defined")) + + def scan_cycle(self): """Execute one scan cycle @@ -101,85 +246,217 @@ def scan_cycle(self): """ # pylint: disable=too-many-branches - # Retune demodulators that are locked out - for demodulator in self.receiver.demodulators: - if demodulator.center_freq in self.lockout_channels: - demodulator.set_center_freq(0, self.center_freq) - else: - pass - - # Grab the FFT data, set threshold, and estimate baseband channels + # Grab the FFT data, set threshold, and estimate baseband signal centers for defining channels self.spectrum = self.receiver.probe_signal_vf.level() threshold = 10**(self.threshold_db/10.0) - channels = np.array(estimate.channel_estimate(self.spectrum, - threshold)) + signal_bins = np.array(estimate.channel_estimate(self.spectrum, threshold)) + + # Convert detected signal peaks from bin indices to baseband frequency in Hz + signal_freqs = (signal_bins - len(self.spectrum)/2) * self.samp_rate / len(self.spectrum) - # Convert channels from bin indices to baseband frequency in Hz - channels = (channels-len(self.spectrum)/2)*\ - self.samp_rate/len(self.spectrum) + # keep signal power with each channel for scanner prioritization (close call priority) + signal_powers = [] + for bindx in range(len(signal_bins)): + signal_powers.append(self.spectrum[bindx]) # Round channels to channel spacing # Note this affects tuning the demodulators # 5000 Hz is adequate for NBFM - channels = np.round(channels / self.channel_spacing) * self.channel_spacing - - # Remove channels that are already in the priority list - temp = [] - for channel in channels: - if channel not in self.priority_channels: - temp = np.append(temp, channel) - else: - pass - channels = temp - - # Put the priority channels in front - channels = np.append(self.priority_channels, channels) + # Note that channel spacing is with respect to the center + baseband offset, + # not just the offset itself + + # TODO should find channels that are less than half channel-spacing away from a provided priority channel + # and replace with priority channel center rather than fixed spacings + #signal_channelized = signal_freqs + self.center_freq + #signal_channelized = np.round(signal_channels / self.channel_spacing) * self.channel_spacing + #signal_channelized = signal_channels - self.center_freq + # in baseband + signal_channels = [] + for fidx in range(len(signal_freqs)): + signal_channels.append(np.round(signal_freqs[fidx] / self.channel_spacing) * self.channel_spacing) + # signal_channels.append((np.round((signal_freqs[fidx] + self.center_freq) / self.channel_spacing) \ + # + self.channel_spacing) - self.center_freq) + + # set active channels for gui highlight before filtering down lockout or adding priority + # TODO revisit how gui highlights active vs priority, use tuple to inform channel, pwr, type(priority/continuing/closecall/regular) + # in baseband + self.active_channels = signal_channels + + # + #sys.stderr.write("\n\n ** gothere **\n\n") + # + + # create list of tuples for bin index, channel frequency, channel power, channel type + # channel type will be enum (priority, continuing, close, normal) + # priority = in priority list from file + # continuing = demod already assigned, signal continues + # close = power more than YYdb above threshold (this is future revision, nothing done for close currently, YY undefined) + # new = power above threshold + + channel_type = ["priority", "continuing", "new"] + channel_type_gui = ["+", " ", "^"] + + # empty list of tuples + # channels_tuple_key = ("index", "signal frequency channel", "signal power", "spectrum bin") + # in baseband + self.unordered_active_channels = [] + for idx in range(len(signal_channels)): + self.unordered_active_channels.append((idx,signal_channels[idx],signal_powers[idx],signal_bins[idx])) + + self.ordered_active_channels = [] # Remove channels that are locked out - temp = [] - for channel in channels: - if channel not in self.lockout_channels: - temp = np.append(temp, channel) + # Remove channels that are outside the requested freq range + for a_chan in self.unordered_active_channels: + if a_chan[1] in self.lockout_channels or a_chan[1] < self.low_bound or a_chan[1] > self.high_bound: + self.unordered_active_channels.remove(a_chan) + + # Put the channels in priority order + # 1 - channels in priority list + # 2 - channels already being monitored that are still active + # 3 - new channels in highest power order + + # 1 - channels in the priority list + # Check active channels for any priority channels + # Check in priority channel order to insure we select higher + # priority channels over lower priority channels per file order + for a_chan in self.unordered_active_channels: + if a_chan[1] in self.priority_channels: + self.ordered_active_channels.append((a_chan[0], a_chan[1], a_chan[2], a_chan[3], channel_type[0], channel_type_gui[0])) + self.unordered_active_channels.remove(a_chan) + + # 2 - channels already being monitored + active_demods = [freq for freq in self.receiver.get_demod_freqs() if freq > 0] + # check on-going signals + for a_chan in self.unordered_active_channels: + if a_chan[1] in active_demods: + self.ordered_active_channels.append((a_chan[0], a_chan[1], a_chan[2], a_chan[3], channel_type[1], channel_type_gui[1])) + self.unordered_active_channels.remove(a_chan) + + # 3 - new channels in highest power order + # take remaining unordered channels, get spectrum power for that channel freq, and reorder + + # for sorting the channel tuples by power level, list.sort(reverse=True,key=sortPwr) + # the sortPwr function returns the third element of the tuple, which is signal power + + self.power_ordered_active_channels = sorted(self.unordered_active_channels, key=lambda a: a[1], reverse=True) + + for a_chan in self.power_ordered_active_channels: + self.ordered_active_channels.append((a_chan[0], a_chan[1], a_chan[2], a_chan[3], channel_type[2], channel_type_gui[2])) + self.unordered_active_channels.remove(a_chan) + self.power_ordered_active_channels.remove(a_chan) + + # channels are ordered + # from this point on we work with ordered_active_channels as channel tuples + # but a simple list of the ordered frequencies is faster for several operations remaining + self.ordered_active_channels_freqs = [] + for a_chan in self.ordered_active_channels: + self.ordered_active_channels_freqs.append(a_chan[1]) + + # Update demodulator last heards and expire old ones + the_now = time.time() + for idx, demod in enumerate(self.receiver.demodulators): + if (demod.tuner_freq != 0) and (demod.tuner_freq not in self.ordered_active_channels_freqs): + if the_now - demod.last_heard > self.hang_time: + demod.set_tuner_freq(0, self.center_freq) else: - pass - channels = temp - - # Set demodulators that are no longer in channel list to 0 Hz - for demodulator in self.receiver.demodulators: - if demodulator.center_freq not in channels: - demodulator.set_center_freq(0, self.center_freq) - else: - pass - - # Add new channels to demodulators - for channel in channels: - # If channel not in demodulators - if channel not in self.receiver.get_demod_freqs(): - # Sequence through each demodulator - for demodulator in self.receiver.demodulators: - # If demodulator is empty and channel not already there - if (demodulator.center_freq == 0) and \ - (channel not in self.receiver.get_demod_freqs()): - # Assing channel to empty demodulator - demodulator.set_center_freq(channel, self.center_freq) - else: - pass - else: - pass - - # Create an tuned channel list of strings for the GUI + # maintain active demod + demod.set_last_heard(the_now) + # Write in channel log file that the channel is off + self.__print_channel_log__(demod.tuner_freq + self.center_freq, False, idx) + + # priority relative to this round of assignments only + demod_priority = [99999] * len(self.receiver.demodulators) + + # mark demodulators already servicing active channels with their relative priority + # priority is index of ordered channels, lower is better + active_channel_covered_flags= [False] * len(self.ordered_active_channels) + for idx, a_chan in enumerate(self.ordered_active_channels): + for jdx, demod in enumerate(self.receiver.demodulators): + if demod.tuner_freq == a_chan[1]: + demod_priority[jdx] = idx + active_channel_covered_flags[idx] = True + + # assign uncovered channels to demodulators in priority order + # should stop this when reaching the max num of demodulators, do not keep replacing last lowest priority + # the lowest priority channel that can be serviced will be the max number of demodulators + num_demod = len(self.receiver.demodulators) + for idx, a_chan in enumerate(self.ordered_active_channels): + if not active_channel_covered_flags[idx] and idx <= num_demod: + # locate lowest prio demod available, with priority lower than current channel priority idx + # low priority is a high priority value, because ordered channel indexes are used as priorities (1 is highest priority) + lowest_prio_demod_idx = -1 + lowest_prio_demod_prio = idx + for kdx, demod in enumerate(self.receiver.demodulators): + if demod_priority[kdx] > lowest_prio_demod_prio: + lowest_prio_demod_prio = demod_priority[kdx] + lowest_prio_demod_idx = kdx + lowest_prio_demod = demod + + # if a lower priority demod was found, replace it with current idx ordered channel, else cannot service this channel + if (lowest_prio_demod_prio > idx): + lowest_prio_demod.set_tuner_freq(a_chan[1], self.center_freq) + active_channel_covered_flags[idx] = True + demod_priority[lowest_prio_demod_idx] = idx + else: + break + + # this resets demodulators to the same frequency when running length is exceeded, which is a file size control + for demod in self.receiver.demodulators: + if demod.time_stamp > 0 and \ + time.time() - demod.time_stamp > \ + self.max_demod_length: + temp_freq = demod.tuner_freq + # clear the demod to reset file + demod.set_tuner_freq(0, self.center_freq) + # reset the demod to its frequency to restart file + demod.set_tuner_freq(temp_freq, self.center_freq) + + # Create tuned channel list of strings for the GUI in MHz # If channel is a zero then use an empty string self.gui_tuned_channels = [] for demod_freq in self.receiver.get_demod_freqs(): if demod_freq == 0: text = "" else: - # Calculate actual RF frequency - gui_tuned_channel = (demod_freq + \ - self.center_freq)/1E6 + # Calculate actual RF frequency in MHz + gui_tuned_channel = (demod_freq + self.center_freq)/1E6 text = '{:.3f}'.format(gui_tuned_channel) self.gui_tuned_channels.append(text) + # Should there be a priority active channel list for GUI? could display other color when priority channel is active versus normal active + + # Create active channel list of strings for the GUI in MHz + # This is any channel above threshold + # do not include priority if not above threshold + # do include lockout if above threshold, this would highlight the active lockout freq for visual info only + self.gui_active_channels = [] + for channel in self.active_channels: + # calculate active channel freq in MHz + gui_active_channel = (channel + self.center_freq)/1E6 + text = '{:.3f}'.format(gui_active_channel) + self.gui_active_channels.append(text) + # Add active channel to recent list for logging if not already there + if gui_active_channel not in self.log_recent_channels: + self.log_recent_channels.append(gui_active_channel) + + # log recently active channels if we are beyond timeout delay from last logging + # clear list of recently active channels after logging + # reset timeout (a low fidelity/effort timer) + cur_timestamp = int(time.time()) + # if cur_timestamp > timeout_timestamp + timeout + if cur_timestamp > (self.log_timeout_last + self.channel_log_timeout): + # set last timeout to this timestamp + self.log_timeout_last = cur_timestamp + # iterate all recent channels print to log + for channel in self.log_recent_channels: + # Write in channel log file that the channel is on + self.__print_channel_log_active__(float(channel)*1E6, True) + # clear recent channels + self.log_recent_channels = [] + + + def add_lockout(self, idx): """Adds baseband frequency to lockout channels and updates GUI list @@ -189,14 +466,22 @@ def add_lockout(self, idx): # Check to make sure index is within the number of demodulators if idx < len(self.receiver.demodulators): # Lockout if not zero and not already locked out - demod_freq = self.receiver.demodulators[idx].center_freq + demod_freq = self.receiver.demodulators[idx].tuner_freq if (demod_freq != 0) and (demod_freq not in self.lockout_channels): self.lockout_channels = np.append(self.lockout_channels, demod_freq) - # Create a lockout channel list of strings for the GUI + # Retune demodulators that are locked out + for demod in self.receiver.demodulators: + if demod.tuner_freq in self.lockout_channels: + demod.set_tuner_freq(0, self.center_freq) + else: + pass + + # Create a lockout channel list of strings for the GUI in MHz self.gui_lockout_channels = [] for lockout_channel in self.lockout_channels: + # lockout channel in MHz gui_lockout_channel = (lockout_channel + \ self.receiver.center_freq)/1E6 text = '{:.3f}'.format(gui_lockout_channel) @@ -214,7 +499,10 @@ def clear_lockout(self): with open(self.lockout_file_name) as lockout_file: lines = lockout_file.read().splitlines() lockout_file.close() - lines = __builtin__.filter(None, lines) + if PY3: + lines = builtins.filter(None, lines) + else: + lines = __builtin__.filter(None, lines) # Convert to baseband frequencies, round, and append for freq in lines: bb_freq = float(freq) - self.center_freq @@ -224,9 +512,10 @@ def clear_lockout(self): else: pass - # Create a lockout channel list of strings for the GUI + # Create a lockout channel list of strings for the GUI in MHz self.gui_lockout_channels = [] for lockout_channel in self.lockout_channels: + # lockout channel in MHz gui_lockout_channel = (lockout_channel + \ self.receiver.center_freq)/1E6 text = '{:.3f}'.format(gui_lockout_channel) @@ -244,7 +533,11 @@ def update_priority(self): with open(self.priority_file_name) as priority_file: lines = priority_file.read().splitlines() priority_file.close() - lines = __builtin__.filter(None, lines) + if PY3: + lines = builtins.filter(None, lines) + else: + lines = __builtin__.filter(None, lines) + # Convert to baseband frequencies, round, and append if within BW for freq in lines: bb_freq = float(freq) - self.center_freq @@ -259,6 +552,7 @@ def update_priority(self): def set_center_freq(self, center_freq): """Sets RF center frequency of hardware and clears lockout channels + Sets low and high demod frequency limits based on provided bounds in command line Args: center_freq (float): Hardware RF center frequency in Hz @@ -267,38 +561,33 @@ def set_center_freq(self, center_freq): self.receiver.set_center_freq(center_freq) self.center_freq = self.receiver.center_freq + # reset min/max based on sample rate + self.min_freq = (self.center_freq - self.samp_rate/2) + self.max_freq = (self.center_freq + self.samp_rate/2) + # reset low/high freq for demod based on new center and bounds from original provided + self.freq_low = self.low_bound + self.center_freq + self.freq_high = self.high_bound + self.center_freq + # cannot set channel freq lower than min sampled freq + if (self.freq_low < self.min_freq): + self.freq_low = self.min_freq + # cannot set channel freq higher than max sampled freq + if (self.freq_high > self.max_freq): + self.freq_high = self.max_freq + # Update the priority since frequency is changing self.update_priority() # Clear the lockout since frequency is changing self.clear_lockout() - def set_gain(self, gain_db): - """Sets gain of RF hardware - - Args: - gain_db (float): Hardware RF gain in dB - """ - self.receiver.set_gain(gain_db) - self.gain_db = self.receiver.gain_db - - def set_if_gain(self, if_gain_db): - """Sets IF gain of RF hardware - - Args: - if_gain_db (float): Hardware IF gain in dB - """ - self.receiver.set_if_gain(if_gain_db) - self.if_gain_db = self.receiver.if_gain_db - - def set_bb_gain(self, bb_gain_db): - """Sets BB gain of RF hardware + def filter_and_set_gains(self, all_gains): + """Set the supported gains and return them Args: - bb_gain_db (float): Hardware BB gain in dB + all_gains (list of dictionary): Supported gains in dB """ - self.receiver.set_bb_gain(bb_gain_db) - self.bb_gain_db = self.receiver.bb_gain_db + self.gains = self.receiver.filter_and_set_gains(all_gains) + return self.gains def set_squelch(self, squelch_db): """Sets squelch of all demodulators @@ -348,7 +637,7 @@ def main(): if len(parser.parser_args) != 0: parser.print_help() #pylint: disable=maybe-no-member - raise SystemExit, 1 + raise(SystemExit, 1) # Create scanner object ask_samp_rate = parser.ask_samp_rate @@ -359,24 +648,32 @@ def main(): record = parser.record lockout_file_name = parser.lockout_file_name priority_file_name = parser.priority_file_name + channel_log_file_name = parser.channel_log_file_name audio_bps = parser.audio_bps + max_demod_length = parser.max_demod_length + channel_spacing = parser.channel_spacing + min_file_size = parser.min_file_size + center_freq = parser.center_freq + freq_low = parser.freq_low + freq_high = parser.freq_high scanner = Scanner(ask_samp_rate, num_demod, type_demod, hw_args, freq_correction, record, lockout_file_name, - priority_file_name, audio_bps) + priority_file_name, channel_log_file_name, + audio_bps, max_demod_length, channel_spacing, + min_file_size, center_freq, freq_low, freq_high) # Set frequency, gain, squelch, and volume - scanner.set_center_freq(parser.center_freq) - scanner.set_gain(parser.gain_db) - scanner.set_if_gain(parser.if_gain_db) - scanner.set_bb_gain(parser.bb_gain_db) - print "\n" - print "Started %s at %.3f Msps" % (hw_args, scanner.samp_rate/1E6) - print "RX at %.3f MHz with %d dB gain" % (scanner.center_freq/1E6, - scanner.gain_db) + scanner.set_center_freq(center_freq) + print("\n") + print("Started %s at %.3f Msps" % (hw_args, scanner.samp_rate/1E6)) + print("RX at %.3f MHz" % (scanner.center_freq/1E6)) + scanner.filter_and_set_gains(parser.gains) + for gain in scanner.gains: + print("gain %s at %d dB" % (gain["name"], gain["value"])) scanner.set_squelch(parser.squelch_db) scanner.set_volume(parser.volume_db) - print "%d demods of type %d at %d dB squelch and %d dB volume" % \ - (num_demod, type_demod, scanner.squelch_db, scanner.volume_db) + print("%d demods of type %d at %d dB squelch and %d dB volume" % \ + (num_demod, type_demod, scanner.squelch_db, scanner.volume_db)) # Create this epmty list to allow printing to screen old_gui_tuned_channels = [] diff --git a/ham2mon_priority_channels_active.png b/ham2mon_priority_channels_active.png new file mode 100644 index 0000000..f7fd79b Binary files /dev/null and b/ham2mon_priority_channels_active.png differ diff --git a/ham2mon_priority_channels_inactive_noise.png b/ham2mon_priority_channels_inactive_noise.png new file mode 100644 index 0000000..4ac76e8 Binary files /dev/null and b/ham2mon_priority_channels_inactive_noise.png differ diff --git a/ham2mon_priority_channels_overmax.png b/ham2mon_priority_channels_overmax.png new file mode 100644 index 0000000..de3e0a1 Binary files /dev/null and b/ham2mon_priority_channels_overmax.png differ diff --git a/ham2mon_processor_usage_2.png b/ham2mon_processor_usage_2.png new file mode 100644 index 0000000..a81621c Binary files /dev/null and b/ham2mon_processor_usage_2.png differ